From cd176e55c55441f306eae244a29dd19e9de8cc47 Mon Sep 17 00:00:00 2001 From: Dawson Coleman Date: Mon, 16 Mar 2020 20:04:56 +0000 Subject: [PATCH] Add ability to set TLS cipher list for listeners Listeners will now be able to each be assigned their own OpenSSL cipher string with a new field: tls_ciphers. There is also a new configuration option, default_listener_ciphers, which specifies the cipher string to assign to new listeners when one is not explicitly specified. Change-Id: I77da6f14063877af0077f2c12df1aab5d5ead187 Depends-On: Id5f4c20abd40dd092558a711987953012d4ae67f Story: 2006627 Task: 36839 --- api-ref/source/parameters.yaml | 16 +++++ .../source/v2/examples/listener-create-curl | 2 +- .../v2/examples/listener-create-request.json | 3 +- .../v2/examples/listener-create-response.json | 3 +- .../v2/examples/listener-show-response.json | 3 +- .../source/v2/examples/listener-update-curl | 2 +- .../v2/examples/listener-update-request.json | 3 +- .../v2/examples/listener-update-response.json | 3 +- .../v2/examples/listeners-list-response.json | 3 +- api-ref/source/v2/listener.inc | 6 ++ etc/octavia.conf | 6 ++ lower-constraints.txt | 2 +- .../backends/agent/api_server/util.py | 2 +- octavia/api/root_controller.py | 6 +- octavia/api/v2/types/listener.py | 4 ++ octavia/common/config.py | 4 ++ octavia/common/constants.py | 9 +++ octavia/common/data_models.py | 3 +- .../haproxy/combined_listeners/jinja_cfg.py | 4 ++ .../combined_listeners/templates/macros.j2 | 7 ++- ...c36b277bfb0_add_listener_ciphers_column.py | 35 +++++++++++ octavia/db/models.py | 1 + octavia/db/prepare.py | 6 ++ octavia/tests/common/sample_data_models.py | 11 +++- .../functional/api/test_root_controller.py | 3 +- .../functional/api/v2/test_load_balancer.py | 5 +- octavia/tests/unit/api/drivers/test_utils.py | 46 ++++++++++++-- .../combined_listeners/test_jinja_cfg.py | 60 +++++++++++++++++-- .../sample_configs/sample_configs_combined.py | 9 ++- .../add-default-ciphers-2eb70b34290711be.yaml | 7 +++ requirements.txt | 2 +- 31 files changed, 247 insertions(+), 29 deletions(-) create mode 100644 octavia/db/migration/alembic_migrations/versions/7c36b277bfb0_add_listener_ciphers_column.py create mode 100644 releasenotes/notes/add-default-ciphers-2eb70b34290711be.yaml diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 794d9bc897..f4ba4f71b0 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -1503,6 +1503,22 @@ timeout_tcp_inspect-optional: min_version: 2.1 required: false type: integer +tls_ciphers: + description: | + List of ciphers in OpenSSL format (colon-separated). + See https://www.openssl.org/docs/man1.1.1/man1/ciphers.html + in: body + min_version: 2.15 + required: true + type: string +tls_ciphers-optional: + description: | + List of ciphers in OpenSSL format (colon-separated). + See https://www.openssl.org/docs/man1.1.1/man1/ciphers.html + in: body + min_version: 2.15 + required: false + type: string tls_container_ref: description: | The reference to the `key manager service diff --git a/api-ref/source/v2/examples/listener-create-curl b/api-ref/source/v2/examples/listener-create-curl index fc96704a72..b3c18022dc 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"]}}' 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"}}' 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 01d78e0fb2..bd1477b85d 100644 --- a/api-ref/source/v2/examples/listener-create-request.json +++ b/api-ref/source/v2/examples/listener-create-request.json @@ -27,6 +27,7 @@ "allowed_cidrs": [ "192.0.2.0/24", "198.51.100.0/24" - ] + ], + "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256" } } diff --git a/api-ref/source/v2/examples/listener-create-response.json b/api-ref/source/v2/examples/listener-create-response.json index f7785ce351..73253441d1 100644 --- a/api-ref/source/v2/examples/listener-create-response.json +++ b/api-ref/source/v2/examples/listener-create-response.json @@ -42,6 +42,7 @@ "allowed_cidrs": [ "192.0.2.0/24", "198.51.100.0/24" - ] + ], + "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256" } } diff --git a/api-ref/source/v2/examples/listener-show-response.json b/api-ref/source/v2/examples/listener-show-response.json index d1268608f8..537c6c2ab8 100644 --- a/api-ref/source/v2/examples/listener-show-response.json +++ b/api-ref/source/v2/examples/listener-show-response.json @@ -42,6 +42,7 @@ "allowed_cidrs": [ "192.0.2.0/24", "198.51.100.0/24" - ] + ], + "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256" } } diff --git a/api-ref/source/v2/examples/listener-update-curl b/api-ref/source/v2/examples/listener-update-curl index 08fab2869b..e59a220394 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"]}}' 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"}}' 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 0ca3fb3c0c..8c16d7a910 100644 --- a/api-ref/source/v2/examples/listener-update-request.json +++ b/api-ref/source/v2/examples/listener-update-request.json @@ -23,6 +23,7 @@ "allowed_cidrs": [ "192.0.2.0/24", "198.51.100.0/24" - ] + ], + "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256" } } diff --git a/api-ref/source/v2/examples/listener-update-response.json b/api-ref/source/v2/examples/listener-update-response.json index df44a1e14c..9f5a7d714c 100644 --- a/api-ref/source/v2/examples/listener-update-response.json +++ b/api-ref/source/v2/examples/listener-update-response.json @@ -42,6 +42,7 @@ "allowed_cidrs": [ "192.0.2.0/24", "198.51.100.0/24" - ] + ], + "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256" } } diff --git a/api-ref/source/v2/examples/listeners-list-response.json b/api-ref/source/v2/examples/listeners-list-response.json index 48b5720e33..cdcfa4ccc0 100644 --- a/api-ref/source/v2/examples/listeners-list-response.json +++ b/api-ref/source/v2/examples/listeners-list-response.json @@ -44,7 +44,8 @@ "allowed_cidrs": [ "192.0.2.0/24", "198.51.100.0/24" - ] + ], + "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256" } ] } diff --git a/api-ref/source/v2/listener.inc b/api-ref/source/v2/listener.inc index 555aaba4a2..4aab8ef9c1 100644 --- a/api-ref/source/v2/listener.inc +++ b/api-ref/source/v2/listener.inc @@ -72,6 +72,7 @@ Response Parameters - timeout_member_connect: timeout_member_connect - timeout_member_data: timeout_member_data - timeout_tcp_inspect: timeout_tcp_inspect + - tls_ciphers: tls_ciphers - updated_at: updated_at Response Example @@ -163,6 +164,7 @@ Request - timeout_member_connect: timeout_member_connect-optional - timeout_member_data: timeout_member_data-optional - timeout_tcp_inspect: timeout_tcp_inspect-optional + - tls_ciphers: tls_ciphers-optional .. _header_insertions: @@ -287,6 +289,7 @@ Response Parameters - timeout_member_connect: timeout_member_connect - timeout_member_data: timeout_member_data - timeout_tcp_inspect: timeout_tcp_inspect + - tls_ciphers: tls_ciphers - updated_at: updated_at Response Example @@ -365,6 +368,7 @@ Response Parameters - timeout_member_connect: timeout_member_connect - timeout_member_data: timeout_member_data - timeout_tcp_inspect: timeout_tcp_inspect + - tls_ciphers: tls_ciphers - updated_at: updated_at Response Example @@ -424,6 +428,7 @@ Request - timeout_member_connect: timeout_member_connect-optional - timeout_member_data: timeout_member_data-optional - timeout_tcp_inspect: timeout_tcp_inspect-optional + - tls_ciphers: tls_ciphers-optional Request Example --------------- @@ -469,6 +474,7 @@ Response Parameters - timeout_member_connect: timeout_member_connect - timeout_member_data: timeout_member_data - timeout_tcp_inspect: timeout_tcp_inspect + - tls_ciphers: tls_ciphers - updated_at: updated_at Response Example diff --git a/etc/octavia.conf b/etc/octavia.conf index 52ba372484..c806449a40 100644 --- a/etc/octavia.conf +++ b/etc/octavia.conf @@ -56,6 +56,12 @@ # Boolean to enable/disable oslo middleware /healthcheck in the Octavia API # healthcheck_enabled = False +# Default cipher string for new TLS-terminated listeners +# Cipher strings are in OpenSSL format, see https://www.openssl.org/docs/man1.1.1/man1/ciphers.html +# This example is the "Broad Compatibility" cipher string from OWASP, +# see https://cheatsheetseries.owasp.org/cheatsheets/TLS_Cipher_String_Cheat_Sheet.html +# default_listener_ciphers = TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256 + [database] # This line MUST be changed to actually run the plugin. # Example: diff --git a/lower-constraints.txt b/lower-constraints.txt index 07e1479381..0f2a5824a8 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -73,7 +73,7 @@ munch==2.2.0 netaddr==0.7.19 netifaces==0.10.4 networkx==1.11 -octavia-lib==1.5.0 +octavia-lib==2.0.0 openstacksdk==0.12.0 os-client-config==1.29.0 os-service-types==1.2.0 diff --git a/octavia/amphorae/backends/agent/api_server/util.py b/octavia/amphorae/backends/agent/api_server/util.py index 22ca99f9c5..ca0643c79f 100644 --- a/octavia/amphorae/backends/agent/api_server/util.py +++ b/octavia/amphorae/backends/agent/api_server/util.py @@ -30,7 +30,7 @@ LOG = logging.getLogger(__name__) FRONTEND_BACKEND_PATTERN = re.compile(r'\n(frontend|backend)\s+(\S+)\n') LISTENER_MODE_PATTERN = re.compile(r'^\s+mode\s+(.*)$', re.MULTILINE) -TLS_CERT_PATTERN = re.compile(r'^\s+bind\s+\S+\s+ssl crt-list\s+(.*)$', +TLS_CERT_PATTERN = re.compile(r'^\s+bind\s+\S+\s+ssl crt-list\s+(\S*)', re.MULTILINE) STATS_SOCKET_PATTERN = re.compile(r'stats socket\s+(\S+)') diff --git a/octavia/api/root_controller.py b/octavia/api/root_controller.py index a3d20d42b1..2fb9384bd0 100644 --- a/octavia/api/root_controller.py +++ b/octavia/api/root_controller.py @@ -107,6 +107,10 @@ class RootController(object): self._add_a_version(versions, 'v2.13', 'v2', 'SUPPORTED', '2019-09-13T00:00:00Z', host_url) # Availability Zones - self._add_a_version(versions, 'v2.14', 'v2', 'CURRENT', + self._add_a_version(versions, 'v2.14', 'v2', 'SUPPORTED', '2019-11-10T00:00:00Z', host_url) + + # TLS version and cipher options + self._add_a_version(versions, 'v2.15', 'v2', 'CURRENT', + '2020-03-10T00:00:00Z', host_url) return {'versions': versions} diff --git a/octavia/api/v2/types/listener.py b/octavia/api/v2/types/listener.py index 9dc513b5ba..4f2a6d0c4a 100644 --- a/octavia/api/v2/types/listener.py +++ b/octavia/api/v2/types/listener.py @@ -62,6 +62,7 @@ class ListenerResponse(BaseListenerType): client_authentication = wtypes.wsattr(wtypes.StringType()) client_crl_container_ref = wtypes.wsattr(wtypes.StringType()) allowed_cidrs = wtypes.wsattr([types.CidrType()]) + tls_ciphers = wtypes.StringType() @classmethod def from_data_model(cls, data_model, children=False): @@ -150,6 +151,7 @@ class ListenerPOST(BaseListenerType): default=constants.CLIENT_AUTH_NONE) client_crl_container_ref = wtypes.StringType(max_length=255) allowed_cidrs = wtypes.wsattr([types.CidrType()]) + tls_ciphers = wtypes.StringType(max_length=2048) class ListenerRootPOST(types.BaseType): @@ -187,6 +189,7 @@ class ListenerPUT(BaseListenerType): wtypes.Enum(str, *constants.SUPPORTED_CLIENT_AUTH_MODES)) client_crl_container_ref = wtypes.StringType(max_length=255) allowed_cidrs = wtypes.wsattr([types.CidrType()]) + tls_ciphers = wtypes.StringType(max_length=2048) class ListenerRootPUT(types.BaseType): @@ -237,6 +240,7 @@ class ListenerSingleCreate(BaseListenerType): default=constants.CLIENT_AUTH_NONE) client_crl_container_ref = wtypes.StringType(max_length=255) allowed_cidrs = wtypes.wsattr([types.CidrType()]) + tls_ciphers = wtypes.StringType(max_length=2048) class ListenerStatusResponse(BaseListenerType): diff --git a/octavia/common/config.py b/octavia/common/config.py index d394975b08..36eda37f7a 100644 --- a/octavia/common/config.py +++ b/octavia/common/config.py @@ -103,6 +103,10 @@ api_opts = [ cfg.BoolOpt('healthcheck_enabled', default=False, help=_("When True, the oslo middleware healthcheck endpoint " "is enabled in the Octavia API.")), + cfg.StrOpt('default_listener_ciphers', + default=constants.CIPHERS_OWASP_SUITE_B, + help=_("Default OpenSSL cipher string (colon-separated) 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 32eee8665a..fcfec2300f 100644 --- a/octavia/common/constants.py +++ b/octavia/common/constants.py @@ -775,3 +775,12 @@ CINDER_ACTION_CREATE_VOLUME = 'create volume' # The nil UUID (used in octavia for deleted references) - RFC 4122 NIL_UUID = '00000000-0000-0000-0000-000000000000' + +# OpenSSL cipher strings +CIPHERS_OWASP_SUITE_B = ('TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:' + 'TLS_AES_128_GCM_SHA256:DHE-RSA-AES256-GCM-SHA384:' + 'DHE-RSA-AES128-GCM-SHA256:' + 'ECDHE-RSA-AES256-GCM-SHA384:' + 'ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-SHA256:' + 'DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:' + 'ECDHE-RSA-AES128-SHA256') diff --git a/octavia/common/data_models.py b/octavia/common/data_models.py index 2db1fcf766..7b205aa688 100644 --- a/octavia/common/data_models.py +++ b/octavia/common/data_models.py @@ -384,7 +384,7 @@ 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): + allowed_cidrs=None, tls_ciphers=None): self.id = id self.project_id = project_id self.name = name @@ -417,6 +417,7 @@ class Listener(BaseDataModel): self.client_authentication = client_authentication self.client_crl_container_id = client_crl_container_id self.allowed_cidrs = allowed_cidrs or [] + self.tls_ciphers = tls_ciphers 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 2f5fb06403..c9972f1fcc 100644 --- a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py +++ b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py @@ -282,6 +282,10 @@ class JinjaTemplater(object): os.path.join(self.base_crt_dir, loadbalancer.id, tls_certs[listener.client_crl_container_id])) + if (listener.protocol == constants.PROTOCOL_TERMINATED_HTTPS and + listener.tls_ciphers is not None): + ret_value['tls_ciphers'] = listener.tls_ciphers + pools = [] pool_gen = (pool for pool in listener.pools if pool.provisioning_status != constants.PENDING_DELETE) diff --git a/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 b/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 index 96a8f957ff..bfb6ccd168 100644 --- a/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 +++ b/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 @@ -43,8 +43,13 @@ peers {{ "%s_peers"|format(loadbalancer.id.replace("-", ""))|trim() }} {% else %} {% set ca_crl_opt = "" %} {% endif %} + {% if listener.tls_ciphers is defined %} + {% set ciphers_opt = "ciphers %s"|format(listener.tls_ciphers)|trim() %} + {% else %} + {% set ciphers_opt = "" %} + {% endif %} bind {{ lb_vip_address }}:{{ listener.protocol_port }} {{ -"%s %s %s"|format(def_crt_opt, client_ca_opt, ca_crl_opt)|trim() }} +"%s %s %s %s"|format(def_crt_opt, client_ca_opt, ca_crl_opt, ciphers_opt)|trim() }} {% endmacro %} diff --git a/octavia/db/migration/alembic_migrations/versions/7c36b277bfb0_add_listener_ciphers_column.py b/octavia/db/migration/alembic_migrations/versions/7c36b277bfb0_add_listener_ciphers_column.py new file mode 100644 index 0000000000..0c074875be --- /dev/null +++ b/octavia/db/migration/alembic_migrations/versions/7c36b277bfb0_add_listener_ciphers_column.py @@ -0,0 +1,35 @@ +# Copyright 2020 Dawson Coleman +# +# 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 ciphers column + +Revision ID: 7c36b277bfb0 +Revises: 8ac4ed24df3a +Create Date: 2020-03-11 02:23:49.097485 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '7c36b277bfb0' +down_revision = '8ac4ed24df3a' + + +def upgrade(): + op.add_column( + 'listener', + sa.Column('tls_ciphers', sa.String(2048), nullable=True) + ) diff --git a/octavia/db/models.py b/octavia/db/models.py index f750bef87f..6f010c85ed 100644 --- a/octavia/db/models.py +++ b/octavia/db/models.py @@ -520,6 +520,7 @@ class Listener(base_models.BASE, base_models.IdMixin, name="fk_listener_client_authentication_mode_name"), nullable=False, default=constants.CLIENT_AUTH_NONE) client_crl_container_id = sa.Column(sa.String(255), nullable=True) + tls_ciphers = sa.Column(sa.String(2048), nullable=True) _tags = orm.relationship( 'Tags', diff --git a/octavia/db/prepare.py b/octavia/db/prepare.py index a5da8797d2..550c2c5d1c 100644 --- a/octavia/db/prepare.py +++ b/octavia/db/prepare.py @@ -103,6 +103,12 @@ def create_listener(listener_dict, lb_id): if 'client_authentication' not in listener_dict: listener_dict['client_authentication'] = constants.CLIENT_AUTH_NONE + + if (listener_dict['protocol'] == constants.PROTOCOL_TERMINATED_HTTPS and + ('tls_ciphers' not in listener_dict or + listener_dict['tls_ciphers'] is None)): + listener_dict['tls_ciphers'] = ( + CONF.api_settings.default_listener_ciphers) return listener_dict diff --git a/octavia/tests/common/sample_data_models.py b/octavia/tests/common/sample_data_models.py index 3d6542b38a..19ac1e449f 100644 --- a/octavia/tests/common/sample_data_models.py +++ b/octavia/tests/common/sample_data_models.py @@ -294,7 +294,9 @@ class SampleDriverDataModels(object): lib_consts.CA_TLS_CONTAINER_DATA: pool_ca_file_content, lib_consts.CRL_CONTAINER_REF: self.pool_crl_container_ref, lib_consts.CRL_CONTAINER_DATA: pool_crl_file_content, - lib_consts.TLS_ENABLED: True + lib_consts.TLS_ENABLED: True, + lib_consts.TLS_CIPHERS: None, + lib_consts.TLS_VERSIONS: None, } self.provider_pool2_dict = copy.deepcopy(self.provider_pool1_dict) @@ -463,7 +465,8 @@ class SampleDriverDataModels(object): self.client_ca_tls_certificate_ref, lib_consts.CLIENT_AUTHENTICATION: constants.CLIENT_AUTH_NONE, 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 } self.test_listener1_dict.update(self._common_test_dict) @@ -530,7 +533,9 @@ class SampleDriverDataModels(object): lib_consts.CLIENT_CA_TLS_CONTAINER_DATA: ca_cert, lib_consts.CLIENT_AUTHENTICATION: constants.CLIENT_AUTH_NONE, 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_VERSIONS: None } 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 422e3f104a..7c60224ae3 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(15, len(version_ids)) + self.assertEqual(16, len(version_ids)) self.assertIn('v2.0', version_ids) self.assertIn('v2.1', version_ids) self.assertIn('v2.2', version_ids) @@ -61,6 +61,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase): self.assertIn('v2.12', version_ids) self.assertIn('v2.13', version_ids) self.assertIn('v2.14', version_ids) + self.assertIn('v2.15', 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_load_balancer.py b/octavia/tests/functional/api/v2/test_load_balancer.py index de8a5455fa..30d2301a08 100644 --- a/octavia/tests/functional/api/v2/test_load_balancer.py +++ b/octavia/tests/functional/api/v2/test_load_balancer.py @@ -2622,7 +2622,8 @@ class TestLoadBalancerGraph(base.BaseAPITest): 'client_ca_tls_container_ref': None, 'client_authentication': constants.CLIENT_AUTH_NONE, 'client_crl_container_ref': None, - 'allowed_cidrs': None + 'allowed_cidrs': None, + 'tls_ciphers': None } if create_sni_containers: create_listener['sni_container_refs'] = create_sni_containers @@ -2668,6 +2669,8 @@ class TestLoadBalancerGraph(base.BaseAPITest): expected_client_crl_container) if expected_allowed_cidrs: expected_listener['allowed_cidrs'] = expected_allowed_cidrs + if create_protocol == constants.PROTOCOL_TERMINATED_HTTPS: + expected_listener['tls_ciphers'] = constants.CIPHERS_OWASP_SUITE_B return create_listener, expected_listener diff --git a/octavia/tests/unit/api/drivers/test_utils.py b/octavia/tests/unit/api/drivers/test_utils.py index d82c0c66ae..ae69c1a84e 100644 --- a/octavia/tests/unit/api/drivers/test_utils.py +++ b/octavia/tests/unit/api/drivers/test_utils.py @@ -16,6 +16,7 @@ from unittest import mock from octavia_lib.api.drivers import data_models as driver_dm from octavia_lib.api.drivers import exceptions as lib_exceptions +from octavia_lib.common import constants as lib_constants from octavia.api.drivers import exceptions as driver_exceptions from octavia.api.drivers import utils @@ -139,6 +140,13 @@ class TestUtils(base.TestCase): 'flavor_id': 'flavor_id', 'provider': 'noop_driver'} ref_listeners = copy.deepcopy(self.sample_data.provider_listeners) + # TODO(johnsom) Remove when versions implemented + for listener in ref_listeners: + delattr(listener, lib_constants.TLS_VERSIONS) + expect_pools = copy.deepcopy(self.sample_data.provider_pools,) + for pool in expect_pools: + delattr(pool, lib_constants.TLS_VERSIONS) + delattr(pool, lib_constants.TLS_CIPHERS) ref_prov_lb_dict = { 'vip_address': self.sample_data.ip_address, 'admin_state_up': True, @@ -150,7 +158,7 @@ class TestUtils(base.TestCase): 'vip_port_id': self.sample_data.port_id, 'vip_qos_policy_id': self.sample_data.qos_policy_id, 'vip_network_id': self.sample_data.network_id, - 'pools': self.sample_data.provider_pools, + 'pools': expect_pools, 'flavor': {'shaved_ice': 'cherry'}, 'name': 'lb1'} vip = data_models.Vip(ip_address=self.sample_data.ip_address, @@ -211,6 +219,9 @@ class TestUtils(base.TestCase): provider_listeners = utils.db_listeners_to_provider_listeners( self.sample_data.test_db_listeners) ref_listeners = copy.deepcopy(self.sample_data.provider_listeners) + # TODO(johnsom) Remove when versions implemented + for listener in ref_listeners: + delattr(listener, lib_constants.TLS_VERSIONS) self.assertEqual(ref_listeners, provider_listeners) @mock.patch('oslo_context.context.RequestContext', return_value=None) @@ -250,6 +261,10 @@ class TestUtils(base.TestCase): # not any other related fields. So we need to delete them. expect_prov = copy.deepcopy(self.sample_data.provider_listener1_dict) expect_pool_prov = copy.deepcopy(self.sample_data.provider_pool1_dict) + # TODO(johnsom) Remove when versions and ciphers are implemented + expect_pool_prov.pop(lib_constants.TLS_VERSIONS) + expect_pool_prov.pop(lib_constants.TLS_CIPHERS) + expect_prov.pop(lib_constants.TLS_VERSIONS) expect_prov['default_pool'] = expect_pool_prov provider_listener = utils.listener_dict_to_provider_dict( self.sample_data.test_listener1_dict) @@ -283,6 +298,10 @@ class TestUtils(base.TestCase): expect_prov = copy.deepcopy(self.sample_data.provider_listener1_dict) expect_pool_prov = copy.deepcopy(self.sample_data.provider_pool1_dict) del expect_pool_prov['tls_container_data'] + # TODO(johnsom) Remove when versions and ciphers are implemented + expect_pool_prov.pop(lib_constants.TLS_VERSIONS) + expect_pool_prov.pop(lib_constants.TLS_CIPHERS) + expect_prov.pop(lib_constants.TLS_VERSIONS) expect_prov['default_pool'] = expect_pool_prov del expect_prov['default_tls_container_data'] del expect_prov['sni_container_data'] @@ -318,7 +337,11 @@ class TestUtils(base.TestCase): 'X509 POOL CRL FILE'] provider_pool = utils.db_pool_to_provider_pool( self.sample_data.db_pool1) - self.assertEqual(self.sample_data.provider_pool1, provider_pool) + # TODO(johnsom) Remove when versions and ciphers are implemented + expect_prov_pool = copy.deepcopy(self.sample_data.provider_pool1) + delattr(expect_prov_pool, lib_constants.TLS_VERSIONS) + delattr(expect_prov_pool, lib_constants.TLS_CIPHERS) + self.assertEqual(expect_prov_pool, provider_pool) @mock.patch('octavia.api.drivers.utils._get_secret_data') @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') @@ -333,7 +356,11 @@ class TestUtils(base.TestCase): test_db_pool = self.sample_data.db_pool1 test_db_pool.members = [self.sample_data.db_member1] provider_pool = utils.db_pool_to_provider_pool(test_db_pool) - self.assertEqual(self.sample_data.provider_pool1, provider_pool) + # TODO(johnsom) Remove when versions and ciphers are implemented + expect_prov_pool = copy.deepcopy(self.sample_data.provider_pool1) + delattr(expect_prov_pool, lib_constants.TLS_VERSIONS) + delattr(expect_prov_pool, lib_constants.TLS_CIPHERS) + self.assertEqual(expect_prov_pool, provider_pool) @mock.patch('octavia.api.drivers.utils._get_secret_data') @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') @@ -346,7 +373,12 @@ class TestUtils(base.TestCase): 'X509 POOL CRL FILE'] provider_pools = utils.db_pools_to_provider_pools( self.sample_data.test_db_pools) - self.assertEqual(self.sample_data.provider_pools, provider_pools) + # TODO(johnsom) Remove when versions and ciphers are implemented + expect_prov_pools = copy.deepcopy(self.sample_data.provider_pools) + for prov_pool in expect_prov_pools: + delattr(prov_pool, lib_constants.TLS_VERSIONS) + delattr(prov_pool, lib_constants.TLS_CIPHERS) + self.assertEqual(expect_prov_pools, provider_pools) @mock.patch('octavia.api.drivers.utils._get_secret_data') @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') @@ -362,6 +394,9 @@ class TestUtils(base.TestCase): provider_pool_dict = utils.pool_dict_to_provider_dict( self.sample_data.test_pool1_dict) provider_pool_dict.pop('crl_container_ref') + # TODO(johnsom) Remove when versions and ciphers are implemented + expect_prov.pop(lib_constants.TLS_VERSIONS) + expect_prov.pop(lib_constants.TLS_CIPHERS) self.assertEqual(expect_prov, provider_pool_dict) @mock.patch('octavia.api.drivers.utils._get_secret_data') @@ -393,6 +428,9 @@ class TestUtils(base.TestCase): provider_pool_dict = utils.pool_dict_to_provider_dict( self.sample_data.test_pool1_dict, for_delete=True) provider_pool_dict.pop('crl_container_ref') + # TODO(johnsom) Remove when versions and ciphers are implemented + expect_prov.pop(lib_constants.TLS_VERSIONS) + expect_prov.pop(lib_constants.TLS_CIPHERS) self.assertEqual(expect_prov, provider_pool_dict) def test_db_HM_to_provider_HM(self): 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 9d6b313dd9..04c43d8801 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 @@ -51,12 +51,13 @@ 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\n" + "certs/sample_loadbalancer_id_1/SHA_ID.pem ciphers {ciphers}\n" " mode http\n" " default_backend sample_pool_id_1:sample_listener_id_1\n" " timeout client 50000\n").format( maxconn=constants.HAPROXY_MAX_MAXCONN, - crt_list=FAKE_CRT_LIST_FILENAME) + 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" @@ -94,6 +95,56 @@ class TestHaproxyCfg(base.TestCase): rendered_obj) def test_render_template_tls_no_sni(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}\n" + " mode http\n" + " default_backend sample_pool_id_1:sample_listener_id_1\n" + " timeout client 50000\n").format( + maxconn=constants.HAPROXY_MAX_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_MAX_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)], + 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_ciphers(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( @@ -129,7 +180,7 @@ class TestHaproxyCfg(base.TestCase): 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)], + proto='TERMINATED_HTTPS', tls=True, tls_ciphers=None)], tls_certs={'cont_id_1': sample_configs_combined.sample_tls_container_tuple( id='tls_container_id', @@ -1121,7 +1172,8 @@ class TestHaproxyCfg(base.TestCase): fe = ("frontend sample_listener_id_1\n" " maxconn 1000000\n" " redirect scheme https if !{ ssl_fc }\n" - " bind 10.0.0.2:443\n" + " bind 10.0.0.2:443 ciphers " + + constants.CIPHERS_OWASP_SUITE_B + "\n" " mode http\n" " acl sample_l7rule_id_1 path -m beg /api\n" " use_backend sample_pool_id_2:sample_listener_id_1" 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 7374231aa2..09758472f2 100644 --- a/octavia/tests/unit/common/sample_configs/sample_configs_combined.py +++ b/octavia/tests/unit/common/sample_configs/sample_configs_combined.py @@ -599,10 +599,13 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True, pool_ca_cert=False, pool_crl=False, tls_enabled=False, hm_host_http_check=False, id='sample_listener_id_1', recursive_nest=False, - provisioning_status=constants.ACTIVE): + provisioning_status=constants.ACTIVE, + tls_ciphers=constants.CIPHERS_OWASP_SUITE_B): 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 topology = 'SINGLE' if topology is None else topology port = '443' if proto in ['HTTPS', 'TERMINATED_HTTPS'] else '80' peer_port = 1024 if peer_port is None else peer_port @@ -616,7 +619,8 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True, 'timeout_member_connect, timeout_member_data, ' 'timeout_tcp_inspect, client_ca_tls_certificate_id, ' 'client_ca_tls_certificate, client_authentication, ' - 'client_crl_container_id, provisioning_status') + 'client_crl_container_id, provisioning_status, ' + 'tls_ciphers') if l7: pools = [ sample_pool_tuple( @@ -727,6 +731,7 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True, constants.CLIENT_AUTH_NONE), client_crl_container_id='cont_id_crl' if client_crl_cert else '', provisioning_status=provisioning_status, + tls_ciphers=tls_ciphers ) if recursive_nest: listener.load_balancer.listeners.append(listener) diff --git a/releasenotes/notes/add-default-ciphers-2eb70b34290711be.yaml b/releasenotes/notes/add-default-ciphers-2eb70b34290711be.yaml new file mode 100644 index 0000000000..1016576133 --- /dev/null +++ b/releasenotes/notes/add-default-ciphers-2eb70b34290711be.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + HTTPS-terminated listeners can now be individually configured with an OpenSSL cipher string. + The default cipher string for new listeners can be specified with ``default_tls_ciphers`` + in ``octavia.conf``. The built-in default is OWASP's "Suite B" recommendation. (https://cheatsheetseries.owasp.org/cheatsheets/TLS_Cipher_String_Cheat_Sheet.html) + Existing listeners will be unaffected. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f365dd2468..a182cb7589 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,7 +44,7 @@ tenacity>=5.0.4 # Apache-2.0 distro>=1.2.0 # Apache-2.0 jsonschema>=2.6.0 # MIT debtcollector>=1.19.0 # Apache-2.0 -octavia-lib>=1.5.0 # Apache-2.0 +octavia-lib>=2.0.0 # Apache-2.0 netaddr>=0.7.19 # BSD simplejson>=3.13.2 # MIT setproctitle>=1.1.10 # BSD