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
This commit is contained in:
Dawson Coleman 2020-03-16 20:04:56 +00:00 committed by Michael Johnson
parent 73fca169ab
commit cd176e55c5
31 changed files with 247 additions and 29 deletions

View File

@ -1503,6 +1503,22 @@ timeout_tcp_inspect-optional:
min_version: 2.1 min_version: 2.1
required: false required: false
type: integer 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: tls_container_ref:
description: | description: |
The reference to the `key manager service The reference to the `key manager service

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"]}}' 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"}}' http://198.51.100.10:9876/v2/lbaas/listeners

View File

@ -27,6 +27,7 @@
"allowed_cidrs": [ "allowed_cidrs": [
"192.0.2.0/24", "192.0.2.0/24",
"198.51.100.0/24" "198.51.100.0/24"
] ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"
} }
} }

View File

@ -42,6 +42,7 @@
"allowed_cidrs": [ "allowed_cidrs": [
"192.0.2.0/24", "192.0.2.0/24",
"198.51.100.0/24" "198.51.100.0/24"
] ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"
} }
} }

View File

@ -42,6 +42,7 @@
"allowed_cidrs": [ "allowed_cidrs": [
"192.0.2.0/24", "192.0.2.0/24",
"198.51.100.0/24" "198.51.100.0/24"
] ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"
} }
} }

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"]}}' 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"}}' http://198.51.100.10:9876/v2/lbaas/listeners/023f2e34-7806-443b-bfae-16c324569a3d

View File

@ -23,6 +23,7 @@
"allowed_cidrs": [ "allowed_cidrs": [
"192.0.2.0/24", "192.0.2.0/24",
"198.51.100.0/24" "198.51.100.0/24"
] ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"
} }
} }

View File

@ -42,6 +42,7 @@
"allowed_cidrs": [ "allowed_cidrs": [
"192.0.2.0/24", "192.0.2.0/24",
"198.51.100.0/24" "198.51.100.0/24"
] ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"
} }
} }

View File

@ -44,7 +44,8 @@
"allowed_cidrs": [ "allowed_cidrs": [
"192.0.2.0/24", "192.0.2.0/24",
"198.51.100.0/24" "198.51.100.0/24"
] ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"
} }
] ]
} }

View File

@ -72,6 +72,7 @@ Response Parameters
- timeout_member_connect: timeout_member_connect - timeout_member_connect: timeout_member_connect
- timeout_member_data: timeout_member_data - timeout_member_data: timeout_member_data
- timeout_tcp_inspect: timeout_tcp_inspect - timeout_tcp_inspect: timeout_tcp_inspect
- tls_ciphers: tls_ciphers
- updated_at: updated_at - updated_at: updated_at
Response Example Response Example
@ -163,6 +164,7 @@ Request
- timeout_member_connect: timeout_member_connect-optional - timeout_member_connect: timeout_member_connect-optional
- timeout_member_data: timeout_member_data-optional - timeout_member_data: timeout_member_data-optional
- timeout_tcp_inspect: timeout_tcp_inspect-optional - timeout_tcp_inspect: timeout_tcp_inspect-optional
- tls_ciphers: tls_ciphers-optional
.. _header_insertions: .. _header_insertions:
@ -287,6 +289,7 @@ Response Parameters
- timeout_member_connect: timeout_member_connect - timeout_member_connect: timeout_member_connect
- timeout_member_data: timeout_member_data - timeout_member_data: timeout_member_data
- timeout_tcp_inspect: timeout_tcp_inspect - timeout_tcp_inspect: timeout_tcp_inspect
- tls_ciphers: tls_ciphers
- updated_at: updated_at - updated_at: updated_at
Response Example Response Example
@ -365,6 +368,7 @@ Response Parameters
- timeout_member_connect: timeout_member_connect - timeout_member_connect: timeout_member_connect
- timeout_member_data: timeout_member_data - timeout_member_data: timeout_member_data
- timeout_tcp_inspect: timeout_tcp_inspect - timeout_tcp_inspect: timeout_tcp_inspect
- tls_ciphers: tls_ciphers
- updated_at: updated_at - updated_at: updated_at
Response Example Response Example
@ -424,6 +428,7 @@ Request
- timeout_member_connect: timeout_member_connect-optional - timeout_member_connect: timeout_member_connect-optional
- timeout_member_data: timeout_member_data-optional - timeout_member_data: timeout_member_data-optional
- timeout_tcp_inspect: timeout_tcp_inspect-optional - timeout_tcp_inspect: timeout_tcp_inspect-optional
- tls_ciphers: tls_ciphers-optional
Request Example Request Example
--------------- ---------------
@ -469,6 +474,7 @@ Response Parameters
- timeout_member_connect: timeout_member_connect - timeout_member_connect: timeout_member_connect
- timeout_member_data: timeout_member_data - timeout_member_data: timeout_member_data
- timeout_tcp_inspect: timeout_tcp_inspect - timeout_tcp_inspect: timeout_tcp_inspect
- tls_ciphers: tls_ciphers
- updated_at: updated_at - updated_at: updated_at
Response Example Response Example

View File

@ -56,6 +56,12 @@
# Boolean to enable/disable oslo middleware /healthcheck in the Octavia API # Boolean to enable/disable oslo middleware /healthcheck in the Octavia API
# healthcheck_enabled = False # 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] [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

@ -73,7 +73,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==1.5.0 octavia-lib==2.0.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

@ -30,7 +30,7 @@ LOG = logging.getLogger(__name__)
FRONTEND_BACKEND_PATTERN = re.compile(r'\n(frontend|backend)\s+(\S+)\n') FRONTEND_BACKEND_PATTERN = re.compile(r'\n(frontend|backend)\s+(\S+)\n')
LISTENER_MODE_PATTERN = re.compile(r'^\s+mode\s+(.*)$', re.MULTILINE) 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) re.MULTILINE)
STATS_SOCKET_PATTERN = re.compile(r'stats socket\s+(\S+)') STATS_SOCKET_PATTERN = re.compile(r'stats socket\s+(\S+)')

View File

@ -107,6 +107,10 @@ class RootController(object):
self._add_a_version(versions, 'v2.13', 'v2', 'SUPPORTED', self._add_a_version(versions, 'v2.13', 'v2', 'SUPPORTED',
'2019-09-13T00:00:00Z', host_url) '2019-09-13T00:00:00Z', host_url)
# Availability Zones # 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) '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} return {'versions': versions}

View File

@ -62,6 +62,7 @@ class ListenerResponse(BaseListenerType):
client_authentication = wtypes.wsattr(wtypes.StringType()) client_authentication = wtypes.wsattr(wtypes.StringType())
client_crl_container_ref = wtypes.wsattr(wtypes.StringType()) client_crl_container_ref = wtypes.wsattr(wtypes.StringType())
allowed_cidrs = wtypes.wsattr([types.CidrType()]) allowed_cidrs = wtypes.wsattr([types.CidrType()])
tls_ciphers = wtypes.StringType()
@classmethod @classmethod
def from_data_model(cls, data_model, children=False): def from_data_model(cls, data_model, children=False):
@ -150,6 +151,7 @@ class ListenerPOST(BaseListenerType):
default=constants.CLIENT_AUTH_NONE) default=constants.CLIENT_AUTH_NONE)
client_crl_container_ref = wtypes.StringType(max_length=255) client_crl_container_ref = wtypes.StringType(max_length=255)
allowed_cidrs = wtypes.wsattr([types.CidrType()]) allowed_cidrs = wtypes.wsattr([types.CidrType()])
tls_ciphers = wtypes.StringType(max_length=2048)
class ListenerRootPOST(types.BaseType): class ListenerRootPOST(types.BaseType):
@ -187,6 +189,7 @@ class ListenerPUT(BaseListenerType):
wtypes.Enum(str, *constants.SUPPORTED_CLIENT_AUTH_MODES)) wtypes.Enum(str, *constants.SUPPORTED_CLIENT_AUTH_MODES))
client_crl_container_ref = wtypes.StringType(max_length=255) client_crl_container_ref = wtypes.StringType(max_length=255)
allowed_cidrs = wtypes.wsattr([types.CidrType()]) allowed_cidrs = wtypes.wsattr([types.CidrType()])
tls_ciphers = wtypes.StringType(max_length=2048)
class ListenerRootPUT(types.BaseType): class ListenerRootPUT(types.BaseType):
@ -237,6 +240,7 @@ class ListenerSingleCreate(BaseListenerType):
default=constants.CLIENT_AUTH_NONE) default=constants.CLIENT_AUTH_NONE)
client_crl_container_ref = wtypes.StringType(max_length=255) client_crl_container_ref = wtypes.StringType(max_length=255)
allowed_cidrs = wtypes.wsattr([types.CidrType()]) allowed_cidrs = wtypes.wsattr([types.CidrType()])
tls_ciphers = wtypes.StringType(max_length=2048)
class ListenerStatusResponse(BaseListenerType): class ListenerStatusResponse(BaseListenerType):

View File

@ -103,6 +103,10 @@ api_opts = [
cfg.BoolOpt('healthcheck_enabled', default=False, cfg.BoolOpt('healthcheck_enabled', default=False,
help=_("When True, the oslo middleware healthcheck endpoint " help=_("When True, the oslo middleware healthcheck endpoint "
"is enabled in the Octavia API.")), "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 # Options only used by the amphora agent

View File

@ -775,3 +775,12 @@ CINDER_ACTION_CREATE_VOLUME = 'create volume'
# The nil UUID (used in octavia for deleted references) - RFC 4122 # The nil UUID (used in octavia for deleted references) - RFC 4122
NIL_UUID = '00000000-0000-0000-0000-000000000000' 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')

View File

@ -384,7 +384,7 @@ 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): allowed_cidrs=None, tls_ciphers=None):
self.id = id self.id = id
self.project_id = project_id self.project_id = project_id
self.name = name self.name = name
@ -417,6 +417,7 @@ class Listener(BaseDataModel):
self.client_authentication = client_authentication self.client_authentication = client_authentication
self.client_crl_container_id = client_crl_container_id self.client_crl_container_id = client_crl_container_id
self.allowed_cidrs = allowed_cidrs or [] self.allowed_cidrs = allowed_cidrs or []
self.tls_ciphers = tls_ciphers
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

@ -282,6 +282,10 @@ class JinjaTemplater(object):
os.path.join(self.base_crt_dir, loadbalancer.id, os.path.join(self.base_crt_dir, loadbalancer.id,
tls_certs[listener.client_crl_container_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 = [] pools = []
pool_gen = (pool for pool in listener.pools if pool_gen = (pool for pool in listener.pools if
pool.provisioning_status != constants.PENDING_DELETE) pool.provisioning_status != constants.PENDING_DELETE)

View File

@ -43,8 +43,13 @@ peers {{ "%s_peers"|format(loadbalancer.id.replace("-", ""))|trim() }}
{% else %} {% else %}
{% set ca_crl_opt = "" %} {% set ca_crl_opt = "" %}
{% endif %} {% 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 }} {{ 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 %} {% endmacro %}

View File

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

View File

@ -520,6 +520,7 @@ class Listener(base_models.BASE, base_models.IdMixin,
name="fk_listener_client_authentication_mode_name"), name="fk_listener_client_authentication_mode_name"),
nullable=False, default=constants.CLIENT_AUTH_NONE) nullable=False, default=constants.CLIENT_AUTH_NONE)
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)
_tags = orm.relationship( _tags = orm.relationship(
'Tags', 'Tags',

View File

@ -103,6 +103,12 @@ def create_listener(listener_dict, lb_id):
if 'client_authentication' not in listener_dict: if 'client_authentication' not in listener_dict:
listener_dict['client_authentication'] = constants.CLIENT_AUTH_NONE 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 return listener_dict

View File

@ -294,7 +294,9 @@ class SampleDriverDataModels(object):
lib_consts.CA_TLS_CONTAINER_DATA: pool_ca_file_content, lib_consts.CA_TLS_CONTAINER_DATA: pool_ca_file_content,
lib_consts.CRL_CONTAINER_REF: self.pool_crl_container_ref, lib_consts.CRL_CONTAINER_REF: self.pool_crl_container_ref,
lib_consts.CRL_CONTAINER_DATA: pool_crl_file_content, 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) self.provider_pool2_dict = copy.deepcopy(self.provider_pool1_dict)
@ -463,7 +465,8 @@ class SampleDriverDataModels(object):
self.client_ca_tls_certificate_ref, self.client_ca_tls_certificate_ref,
lib_consts.CLIENT_AUTHENTICATION: constants.CLIENT_AUTH_NONE, lib_consts.CLIENT_AUTHENTICATION: constants.CLIENT_AUTH_NONE,
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
} }
self.test_listener1_dict.update(self._common_test_dict) 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_CA_TLS_CONTAINER_DATA: ca_cert,
lib_consts.CLIENT_AUTHENTICATION: constants.CLIENT_AUTH_NONE, lib_consts.CLIENT_AUTHENTICATION: constants.CLIENT_AUTH_NONE,
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_VERSIONS: None
} }
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(15, len(version_ids)) self.assertEqual(16, 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)
@ -61,6 +61,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase):
self.assertIn('v2.12', version_ids) self.assertIn('v2.12', version_ids)
self.assertIn('v2.13', version_ids) self.assertIn('v2.13', version_ids)
self.assertIn('v2.14', 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 # 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

@ -2622,7 +2622,8 @@ class TestLoadBalancerGraph(base.BaseAPITest):
'client_ca_tls_container_ref': None, 'client_ca_tls_container_ref': None,
'client_authentication': constants.CLIENT_AUTH_NONE, 'client_authentication': constants.CLIENT_AUTH_NONE,
'client_crl_container_ref': None, 'client_crl_container_ref': None,
'allowed_cidrs': None 'allowed_cidrs': None,
'tls_ciphers': 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
@ -2668,6 +2669,8 @@ class TestLoadBalancerGraph(base.BaseAPITest):
expected_client_crl_container) expected_client_crl_container)
if expected_allowed_cidrs: if expected_allowed_cidrs:
expected_listener['allowed_cidrs'] = 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 return create_listener, expected_listener

View File

@ -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 data_models as driver_dm
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_constants
from octavia.api.drivers import exceptions as driver_exceptions from octavia.api.drivers import exceptions as driver_exceptions
from octavia.api.drivers import utils from octavia.api.drivers import utils
@ -139,6 +140,13 @@ class TestUtils(base.TestCase):
'flavor_id': 'flavor_id', 'flavor_id': 'flavor_id',
'provider': 'noop_driver'} 'provider': 'noop_driver'}
ref_listeners = copy.deepcopy(self.sample_data.provider_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)
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 = { ref_prov_lb_dict = {
'vip_address': self.sample_data.ip_address, 'vip_address': self.sample_data.ip_address,
'admin_state_up': True, 'admin_state_up': True,
@ -150,7 +158,7 @@ class TestUtils(base.TestCase):
'vip_port_id': self.sample_data.port_id, 'vip_port_id': self.sample_data.port_id,
'vip_qos_policy_id': self.sample_data.qos_policy_id, 'vip_qos_policy_id': self.sample_data.qos_policy_id,
'vip_network_id': self.sample_data.network_id, 'vip_network_id': self.sample_data.network_id,
'pools': self.sample_data.provider_pools, 'pools': expect_pools,
'flavor': {'shaved_ice': 'cherry'}, 'flavor': {'shaved_ice': 'cherry'},
'name': 'lb1'} 'name': 'lb1'}
vip = data_models.Vip(ip_address=self.sample_data.ip_address, 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( provider_listeners = utils.db_listeners_to_provider_listeners(
self.sample_data.test_db_listeners) self.sample_data.test_db_listeners)
ref_listeners = copy.deepcopy(self.sample_data.provider_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) self.assertEqual(ref_listeners, provider_listeners)
@mock.patch('oslo_context.context.RequestContext', return_value=None) @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. # not any other related fields. So we need to delete them.
expect_prov = copy.deepcopy(self.sample_data.provider_listener1_dict) expect_prov = copy.deepcopy(self.sample_data.provider_listener1_dict)
expect_pool_prov = copy.deepcopy(self.sample_data.provider_pool1_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 expect_prov['default_pool'] = expect_pool_prov
provider_listener = utils.listener_dict_to_provider_dict( provider_listener = utils.listener_dict_to_provider_dict(
self.sample_data.test_listener1_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_prov = copy.deepcopy(self.sample_data.provider_listener1_dict)
expect_pool_prov = copy.deepcopy(self.sample_data.provider_pool1_dict) expect_pool_prov = copy.deepcopy(self.sample_data.provider_pool1_dict)
del expect_pool_prov['tls_container_data'] 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 expect_prov['default_pool'] = expect_pool_prov
del expect_prov['default_tls_container_data'] del expect_prov['default_tls_container_data']
del expect_prov['sni_container_data'] del expect_prov['sni_container_data']
@ -318,7 +337,11 @@ class TestUtils(base.TestCase):
'X509 POOL CRL FILE'] 'X509 POOL CRL FILE']
provider_pool = utils.db_pool_to_provider_pool( provider_pool = utils.db_pool_to_provider_pool(
self.sample_data.db_pool1) 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.api.drivers.utils._get_secret_data')
@mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_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 = self.sample_data.db_pool1
test_db_pool.members = [self.sample_data.db_member1] test_db_pool.members = [self.sample_data.db_member1]
provider_pool = utils.db_pool_to_provider_pool(test_db_pool) 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.api.drivers.utils._get_secret_data')
@mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
@ -346,7 +373,12 @@ class TestUtils(base.TestCase):
'X509 POOL CRL FILE'] 'X509 POOL CRL FILE']
provider_pools = utils.db_pools_to_provider_pools( provider_pools = utils.db_pools_to_provider_pools(
self.sample_data.test_db_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.api.drivers.utils._get_secret_data')
@mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_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( provider_pool_dict = utils.pool_dict_to_provider_dict(
self.sample_data.test_pool1_dict) self.sample_data.test_pool1_dict)
provider_pool_dict.pop('crl_container_ref') 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) self.assertEqual(expect_prov, provider_pool_dict)
@mock.patch('octavia.api.drivers.utils._get_secret_data') @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( provider_pool_dict = utils.pool_dict_to_provider_dict(
self.sample_data.test_pool1_dict, for_delete=True) self.sample_data.test_pool1_dict, for_delete=True)
provider_pool_dict.pop('crl_container_ref') 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) self.assertEqual(expect_prov, provider_pool_dict)
def test_db_HM_to_provider_HM(self): def test_db_HM_to_provider_HM(self):

View File

@ -51,12 +51,13 @@ 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\n" "certs/sample_loadbalancer_id_1/SHA_ID.pem ciphers {ciphers}\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_MAX_MAXCONN, 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" be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n" " mode http\n"
" balance roundrobin\n" " balance roundrobin\n"
@ -94,6 +95,56 @@ class TestHaproxyCfg(base.TestCase):
rendered_obj) rendered_obj)
def test_render_template_tls_no_sni(self): 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 = oslo_fixture.Config(cfg.CONF)
conf.config(group="haproxy_amphora", base_cert_dir='/fake_cert_dir') conf.config(group="haproxy_amphora", base_cert_dir='/fake_cert_dir')
FAKE_CRT_LIST_FILENAME = os.path.join( FAKE_CRT_LIST_FILENAME = os.path.join(
@ -129,7 +180,7 @@ class TestHaproxyCfg(base.TestCase):
rendered_obj = self.jinja_cfg.render_loadbalancer_obj( rendered_obj = self.jinja_cfg.render_loadbalancer_obj(
sample_configs_combined.sample_amphora_tuple(), sample_configs_combined.sample_amphora_tuple(),
[sample_configs_combined.sample_listener_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': tls_certs={'cont_id_1':
sample_configs_combined.sample_tls_container_tuple( sample_configs_combined.sample_tls_container_tuple(
id='tls_container_id', id='tls_container_id',
@ -1121,7 +1172,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n" fe = ("frontend sample_listener_id_1\n"
" maxconn 1000000\n" " maxconn 1000000\n"
" redirect scheme https if !{ ssl_fc }\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" " 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"

View File

@ -599,10 +599,13 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
pool_ca_cert=False, pool_crl=False, pool_ca_cert=False, pool_crl=False,
tls_enabled=False, hm_host_http_check=False, tls_enabled=False, hm_host_http_check=False,
id='sample_listener_id_1', recursive_nest=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 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:
tls_ciphers = None
topology = 'SINGLE' if topology is None else topology topology = 'SINGLE' if topology is None else topology
port = '443' if proto in ['HTTPS', 'TERMINATED_HTTPS'] else '80' port = '443' if proto in ['HTTPS', 'TERMINATED_HTTPS'] else '80'
peer_port = 1024 if peer_port is None else peer_port 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_member_connect, timeout_member_data, '
'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')
if l7: if l7:
pools = [ pools = [
sample_pool_tuple( sample_pool_tuple(
@ -727,6 +731,7 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
constants.CLIENT_AUTH_NONE), constants.CLIENT_AUTH_NONE),
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
) )
if recursive_nest: if recursive_nest:
listener.load_balancer.listeners.append(listener) listener.load_balancer.listeners.append(listener)

View File

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

View File

@ -44,7 +44,7 @@ tenacity>=5.0.4 # Apache-2.0
distro>=1.2.0 # Apache-2.0 distro>=1.2.0 # Apache-2.0
jsonschema>=2.6.0 # MIT jsonschema>=2.6.0 # MIT
debtcollector>=1.19.0 # Apache-2.0 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 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