Skip to content

Commit

Permalink
Support checking service ports with ssl connection
Browse files Browse the repository at this point in the history
By default netcat is used to check if a service is
listening on a port. This is generally ok except
for services expecting SSL connections which need
to be properly closed and netcat can't do that. So
here we add support for optionally using the python
ssl library to create an ssl connection to the port
and close it properly once finished.

Related-Bug: #1920770
  • Loading branch information
dosaboy committed Mar 25, 2024
1 parent b78107d commit f5101d8
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 7 deletions.
25 changes: 25 additions & 0 deletions charmhelpers/contrib/network/ip.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import re
import subprocess
import socket
import ssl

from functools import partial

Expand Down Expand Up @@ -542,6 +543,30 @@ def port_has_listener(address, port):
return not (bool(result))


def port_has_listener_ssl(address, port, key, cert, ca_cert):
"""
Returns True if the address:port is open and being listened to,
else False.
@param address: an IP address or hostname
@param port: integer port
@param: cert: path to cert
@param: key: path to key
@param: ca_cert: path to ca cert
"""
hostname = address
context = ssl.create_default_context()
context.check_hostname = False
context.load_cert_chain(cert, key)
context.load_verify_locations(ca_cert)
try:
with socket.create_connection((hostname, port)) as sock:
with context.wrap_socket(sock, server_hostname=hostname):
return True
except ConnectionRefusedError:
return False


def assert_charm_supports_ipv6():
"""Check whether we are able to support charms ipv6."""
release = lsb_release()['DISTRIB_CODENAME'].lower()
Expand Down
54 changes: 47 additions & 7 deletions charmhelpers/contrib/openstack/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
get_ipv6_addr,
is_ipv6,
port_has_listener,
port_has_listener_ssl,
)

from charmhelpers.core.host import (
Expand Down Expand Up @@ -1207,12 +1208,19 @@ def _ows_check_services_running(services, ports):
return ows_check_services_running(services, ports)


def ows_check_services_running(services, ports):
def ows_check_services_running(services, ports, use_ssl_check=False,
ssl_check_info=None):
"""Check that the services that should be running are actually running
and that any ports specified are being listened to.
@param services: list of strings OR dictionary specifying services/ports
@param ports: list of ports
@param use_ssl_check: Set to True if you want to use ssl to check
service ports rather than netcat. Default is
False.
@param ssl_check_info: If use_ssl_check is True this is a dict of
{key: <path to key>, cert: <path to cert>} used
by ssl when checking ports using SSL.
@returns state, message: strings or None, None
"""
messages = []
Expand All @@ -1228,7 +1236,9 @@ def ows_check_services_running(services, ports):
# also verify that the ports that should be open are open
# NB, that ServiceManager objects only OPTIONALLY have ports
map_not_open, ports_open = (
_check_listening_on_services_ports(services))
_check_listening_on_services_ports(services,
use_ssl_check=use_ssl_check,
ssl_check_info=ssl_check_info))
if not all(ports_open):
# find which service has missing ports. They are in service
# order which makes it a bit easier.
Expand All @@ -1243,7 +1253,10 @@ def ows_check_services_running(services, ports):

if ports is not None:
# and we can also check ports which we don't know the service for
ports_open, ports_open_bools = _check_listening_on_ports_list(ports)
ports_open, ports_open_bools = \
_check_listening_on_ports_list(ports,
use_ssl_check=use_ssl_check,
ssl_check_info=ssl_check_info)
if not all(ports_open_bools):
messages.append(
"Ports which should be open, but are not: {}"
Expand Down Expand Up @@ -1302,7 +1315,9 @@ def _check_running_services(services):
return list(zip(services, services_running)), services_running


def _check_listening_on_services_ports(services, test=False):
def _check_listening_on_services_ports(services, test=False,
use_ssl_check=False,
ssl_check_info=None):
"""Check that the unit is actually listening (has the port open) on the
ports that the service specifies are open. If test is True then the
function returns the services with ports that are open rather than
Expand All @@ -1312,11 +1327,23 @@ def _check_listening_on_services_ports(services, test=False):
@param services: OrderedDict(service: [port, ...], ...)
@param test: default=False, if False, test for closed, otherwise open.
@param use_ssl_check: Set to True if you want to use ssl to check
service ports rather than netcat. Default is
False.
@param ssl_check_info: If use_ssl_check is True this is a dict of
{key: <path to key>, cert: <path to cert>} used
by ssl when checking ports using SSL.
@returns OrderedDict(service: [port-not-open, ...]...), [boolean]
"""
test = not (not (test)) # ensure test is True or False
all_ports = list(itertools.chain(*services.values()))
ports_states = [port_has_listener('0.0.0.0', p) for p in all_ports]
if use_ssl_check:
def f_port_listener_check(*args):
return port_has_listener_ssl(*args, **ssl_check_info)
else:
f_port_listener_check = port_has_listener

ports_states = [f_port_listener_check('0.0.0.0', p) for p in all_ports]
map_ports = OrderedDict()
matched_ports = [p for p, opened in zip(all_ports, ports_states)
if opened == test] # essentially opened xor test
Expand All @@ -1327,16 +1354,29 @@ def _check_listening_on_services_ports(services, test=False):
return map_ports, ports_states


def _check_listening_on_ports_list(ports):
def _check_listening_on_ports_list(ports, use_ssl_check=False,
ssl_check_info=None):
"""Check that the ports list given are being listened to
Returns a list of ports being listened to and a list of the
booleans.
@param use_ssl_check: Set to True if you want to use ssl to check
service ports rather than netcat. Default is
False.
@param ssl_check_info: If use_ssl_check is True this is a dict of
{key: <path to key>, cert: <path to cert>} used
by ssl when checking ports using SSL.
@param ports: LIST of port numbers.
@returns [(port_num, boolean), ...], [boolean]
"""
ports_open = [port_has_listener('0.0.0.0', p) for p in ports]
if use_ssl_check:
def f_port_listener_check(*args):
return port_has_listener_ssl(*args, **ssl_check_info)
else:
f_port_listener_check = port_has_listener

ports_open = [f_port_listener_check('0.0.0.0', p) for p in ports]
return zip(ports, ports_open), ports_open


Expand Down
48 changes: 48 additions & 0 deletions tests/contrib/network/test_ip.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import subprocess
import unittest
from contextlib import contextmanager

import mock
import netifaces
Expand Down Expand Up @@ -784,6 +785,53 @@ def test_port_has_listener(self, subprocess_call):
self.assertEqual(net_ip.port_has_listener('ip-address', 70), True)
subprocess_call.assert_called_with(['nc', '-z', 'ip-address', '70'])

@patch('charmhelpers.contrib.network.ip.socket')
@patch('charmhelpers.contrib.network.ip.ssl')
def test_port_has_listener_ssl(self, mock_ssl, mock_socket):
ctxt = mock.MagicMock()
mock_ssl.create_default_context.return_value = ctxt

@contextmanager
def mock_create_connection(*args, **kwargs):
for x in [1]:
yield x

@contextmanager
def mock_wrap_socket(*args, **kwargs):
for x in [1]:
yield x

ctxt.wrap_socket = mock_wrap_socket
mock_socket.create_connection = mock_create_connection
self.assertEqual(net_ip.port_has_listener_ssl('10.0.0.1', 50,
'/etc/ssl/key',
'/etc/ssl/cert',
'/etc/ssl/ca_cert'),
True)

@patch('charmhelpers.contrib.network.ip.socket')
@patch('charmhelpers.contrib.network.ip.ssl')
def test_port_has_listener_ssl_false(self, mock_ssl, mock_socket):
ctxt = mock.MagicMock()
mock_ssl.create_default_context.return_value = ctxt

@contextmanager
def mock_create_connection(*args, **kwargs):
raise ConnectionRefusedError

@contextmanager
def mock_wrap_socket(*args, **kwargs):
for x in [1]:
yield x

ctxt.wrap_socket = mock_wrap_socket
mock_socket.create_connection = mock_create_connection
self.assertEqual(net_ip.port_has_listener_ssl('10.0.0.1', 50,
'/etc/ssl/key',
'/etc/ssl/cert',
'/etc/ssl/ca_cert'),
False)

@patch.object(net_ip, 'log', lambda *args, **kwargs: None)
@patch.object(net_ip, 'config')
@patch.object(net_ip, 'network_get_primary_address')
Expand Down

0 comments on commit f5101d8

Please sign in to comment.