[Asterisk-code-review] sipp, test suite utils: Default media port to an unused port (testsuite[14])

Jenkins2 asteriskteam at digium.com
Mon Apr 23 07:57:49 CDT 2018


Jenkins2 has submitted this change and it was merged. ( https://gerrit.asterisk.org/8746 )

Change subject: sipp, test_suite_utils: Default media port to an unused port
......................................................................

sipp, test_suite_utils: Default media port to an unused port

SIPp correctly chooses an available port for audio, but unfortunately it then
attempts to bind to the audio port + n for things like rtcp and video without
first checking if those other ports are unused:

https://github.com/SIPp/sipp/issues/276

This patch makes it so all ports needed by the scenario are available. It does
this by retrieving and checking for an unused port plus 'n' ports from the OS.
If the ports are available then the primary port is passed to the scenario using
the '-mp' option.

Change-Id: I3da461123afc30e1f5ca12e65d289eaa42d6de00
---
A lib/python/asterisk/self_test/test_utils_socket.py
M lib/python/asterisk/sipp.py
A lib/python/asterisk/utils_socket.py
3 files changed, 502 insertions(+), 0 deletions(-)

Approvals:
  Corey Farrell: Looks good to me, but someone else must approve
  Benjamin Keith Ford: Looks good to me, but someone else must approve
  Joshua Colp: Looks good to me, approved
  Jenkins2: Approved for Submit



diff --git a/lib/python/asterisk/self_test/test_utils_socket.py b/lib/python/asterisk/self_test/test_utils_socket.py
new file mode 100755
index 0000000..3a0c33d
--- /dev/null
+++ b/lib/python/asterisk/self_test/test_utils_socket.py
@@ -0,0 +1,175 @@
+#!/usr/bin/env python
+"""Utility module for socket handling
+
+This module provides tests for the utils_socket module.
+
+Copyright (C) 2018, Digium, Inc.
+Kevin Harwell <kharwell at digium.com>
+
+This program is free software, distributed under the terms of
+the GNU General Public License Version 2.
+"""
+
+import logging
+import sys
+import unittest
+
+from socket import SOCK_STREAM, SOCK_DGRAM, AF_INET, AF_INET6
+
+sys.path.append('lib/python')  # noqa
+from asterisk.utils_socket import Ports, PortError, get_available_port, MIN_PORT
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+class PortTests(unittest.TestCase):
+    """Unit tests for port availability and reservations."""
+
+    def setUp(self):
+        self.ports = Ports()
+
+    def _on_family(self, host, socktype, cb):
+        host = host or ''
+
+        if '.' in host:
+            cb(host, socktype, AF_INET)
+        elif ':' in host:
+            cb(host, socktype, AF_INET6)
+        else:
+            for family in [AF_INET, AF_INET6]:
+                cb(host, socktype, family)
+
+    def _on_socktype(self, host, cb):
+        for socktype in [SOCK_STREAM, SOCK_DGRAM]:
+            self._on_family(host, socktype, cb)
+
+    def _on_host(self, cb):
+        self._on_socktype('', cb)
+
+        for host in ['', '0.0.0.0', '127.0.0.1', '::', '::1']:
+            self._on_socktype(host, cb)
+
+    def test_001_reserve(self):
+        """Test reserving ports for different families and types"""
+
+        p = [51234, 51235]
+
+        def reserve(h, s, f):
+            self.ports.reserve(p, s, f)
+        self._on_socktype(None, reserve)
+
+        def test(h, s, f):
+            self.assertEqual(self.ports.reserved_ports[s][f], p)
+        self._on_socktype(None, test)
+
+    def test_002_is_reserved(self):
+        """Test if port is and is not reserved for families and types"""
+
+        p = 51234
+
+        def reserve(h, s, f):
+            self.ports.reserve(p, s, f)
+        self._on_socktype(None, reserve)
+
+        self.assertTrue(self.ports.is_reserved(MIN_PORT - 1))
+
+        def test(h, s, f):
+            self.assertTrue(self.ports.is_reserved(p, s, f))
+        self._on_socktype(None, test)
+
+        def test2(h, s, f):
+            self.assertFalse(self.ports.is_reserved(71234, s, f))
+        self._on_socktype(None, test2)
+
+    def test_003_get_avail(self):
+        """Test retrieving a ports by families and types"""
+
+        p = [0, 0]
+
+        def test(h, s, f):
+            self.assertNotEqual(self.ports.get_avail(h, p, s, f), p)
+        self._on_host(test)
+
+        p = [51234, 51235]
+
+        def test2(h, s, f):
+            self.assertEqual(self.ports.get_avail(h, p, s, f), p)
+        self._on_host(test2)
+
+        def reserve(h, s, f):
+            self.ports.reserve(p, s, f)
+        self._on_socktype(None, reserve)
+
+        def test3(h, s, f):
+            self.assertRaises(PortError, self.ports.get_avail, h, p, s, f)
+        self._on_host(test3)
+
+    def test_004_get_range(self):
+        """Test retrieving a range of ports by families and types"""
+        p = 0
+
+        def test(h, s, f):
+            self.assertEqual(len(self.ports.get_range(h, p, s, f, 3)), 3)
+        self._on_host(test)
+
+        p = 50000
+
+        def test2(h, s, f):
+            self.assertEqual(self.ports.get_range(h, p, s, f, 3),
+                             [50000, 50001, 50002])
+        self._on_host(test2)
+
+        def test3(h, s, f):
+            self.assertEqual(self.ports.get_range(h, p, s, f, -3),
+                             [50000, 49999, 49998])
+        self._on_host(test3)
+
+    def test_005_get_range_and_reserve(self):
+        """Test retrieving and reserving a range of ports by
+        families and types"""
+        p = 50000
+
+        def get_range(h, s, f):
+            self.ports.get_range_and_reserve(h, p, s, f, 3)
+        self._on_socktype(None, get_range)
+
+        def test(h, s, f):
+            self.assertTrue(self.ports.is_reserved(50000, s, f))
+        self._on_host(test)
+
+        def test2(h, s, f):
+            self.assertTrue(self.ports.is_reserved(50001, s, f))
+        self._on_host(test2)
+
+        def test3(h, s, f):
+            self.assertTrue(self.ports.is_reserved(50002, s, f))
+        self._on_host(test3)
+
+    def test_006_get_available_port(self):
+        """Test retrieving an available port"""
+        p = 50000
+
+        def test(h, s, f):
+            self.assertEqual(get_available_port(h, s, f, 0, p), p)
+        # Limit to single host since using global object
+        self._on_socktype(None, test)
+
+        p = 50001
+
+        self.assertEqual(get_available_port(
+            config={'-i': '0.0.0.0'}, num=2, port=p), p)
+        self.assertEqual(get_available_port(
+            config={'-i': '0.0.0.0',  '-t': ''}, num=2, port=p), p)
+        self.assertEqual(get_available_port(
+            config={'-i': '[::]'}, num=2, port=p), p)
+        self.assertEqual(get_available_port(
+            config={'-i': '[::]',  '-t': ''}, num=2, port=p), p)
+
+
+if __name__ == "__main__":
+    """Run the unit tests"""
+
+    logging.basicConfig(stream=sys.stdout, level=logging.DEBUG,
+                        format="%(module)s:%(lineno)d - %(message)s")
+    unittest.main()
diff --git a/lib/python/asterisk/sipp.py b/lib/python/asterisk/sipp.py
index acdb20d..a703b58 100644
--- a/lib/python/asterisk/sipp.py
+++ b/lib/python/asterisk/sipp.py
@@ -15,6 +15,7 @@
 from abc import ABCMeta, abstractmethod
 from twisted.internet import reactor, defer, protocol, error
 from test_case import TestCase
+from utils_socket import get_available_port
 
 LOGGER = logging.getLogger(__name__)
 
@@ -671,6 +672,19 @@
             default_args['-oocsf'] = ('%s/sipp/%s' % (
                 self.test_dir, default_args['-oocsf']))
 
+        if '-mp' not in default_args:
+            # Current SIPp correctly chooses an available port for audio, but
+            # unfortunately it then attempts to bind to the audio port + n for
+            # things like rtcp and video without first checking if those other
+            # ports are unused (https://github.com/SIPp/sipp/issues/276).
+            #
+            # So as a work around, if not given, we'll specify the media port
+            # ourselves, and make sure all associated ports are available.
+            #
+            # num = 4 = ports for audio rtp/rtcp and video rtp/rtcp
+            default_args['-mp'] = str(get_available_port(
+                config=default_args, num=4))
+
         for (key, val) in default_args.items():
             sipp_args.extend([key, val])
         sipp_args.extend(self.positional_args)
diff --git a/lib/python/asterisk/utils_socket.py b/lib/python/asterisk/utils_socket.py
new file mode 100644
index 0000000..2a11c97
--- /dev/null
+++ b/lib/python/asterisk/utils_socket.py
@@ -0,0 +1,313 @@
+"""Utility module for socket handling
+
+This module provides classes and wrappers around the socket library.
+
+Copyright (C) 2018, Digium, Inc.
+Kevin Harwell <kharwell at digium.com>
+
+This program is free software, distributed under the terms of
+the GNU General Public License Version 2.
+"""
+
+import logging
+
+from socket import *
+
+
+LOGGER = logging.getLogger(__name__)
+
+# Number of times to try and find an available (non-reserved and unused) port
+PORT_ATTEMPTS = 100
+
+# Don't allow any port to be retrieved and reserved below this value
+MIN_PORT = 10000
+
+
+def socket_type(socktype):
+    """Retrieve a string representation of the socket type."""
+    return {
+        SOCK_STREAM: 'TCP',
+        SOCK_DGRAM: 'UDP',
+    }.get(socktype, 'Unknown Type')
+
+
+def socket_family(family):
+    """Retrieve a string representation of the socket family."""
+    return {
+        AF_INET: 'IPv4',
+        AF_INET6: 'IPv6',
+    }.get(family, 'Unknown Family')
+
+
+def get_unused_os_port(host='', port=0, socktype=SOCK_STREAM, family=AF_INET):
+    """Retrieve an unused port from the OS.
+
+    Host can be an empty string, or a specific address. However, if an address
+    is specified the family must coincide with the address or an exception is
+    thrown. For example:
+
+    host='', family=<either AF_INET or AF_INET6>
+    host='0.0.0.0', family=AF_INET
+    host='127.0.0.1', family=AF_INET
+    host='::', family=AF_INET6
+    host='::1', family=AF_INET6
+
+    If the given port is equal to zero then an available port is chosen by the
+    operating system and returned. If a number greater than zero is given for
+    the port, then this checks if that port is currently not in use (from the
+    operating system's perspective). If unused then it's returned. Zero is
+    returned if a port is unavailable.
+
+    Keyword Arguments:
+    host - The host address
+    port - Port number to check availability
+    socktype - socket types (SOCK_STREAM, SOCK_DGRAM, etc.)
+    family - protocol family (AF_INET, AF_INET6, etc.)
+
+    Return:
+    A usable port, or zero if a port is unavailable.
+    """
+
+    host = host.lstrip('[').rstrip(']')
+
+    res = 0
+    s = socket(family, socktype)
+    try:
+        s.bind((host, port))
+        res = s.getsockname()[1]
+    except error as e:
+        # errno = 98 is 'Port already in use'. However, if any error occurs
+        # just fail since we probably don't want to bind to it anyway.
+        LOGGER.debug("{0}/{1} port '{2}' is in use".format(
+            socket_type(socktype), socket_family(family), port))
+
+    s.close()
+    return res
+
+
+class PortError(ValueError):
+    """Error raised when the number of attempts to find an available
+    port is exceeded"""
+
+    def __init__(self, socktype, family, port=0, attempts=PORT_ATTEMPTS):
+        """Create a port error"""
+        if port:
+            msg = "{0}/{1} port '{2}' not available ".format(
+                socket_type(socktype), socket_family(family), port)
+        else:
+            msg = "Unable to get usable {0}/{1} port after {2} attempts".format(
+                socket_type(socktype), socket_family(family), attempts)
+        super(ValueError, self).__init__(msg)
+
+
+class Ports(object):
+    """An interface for port availability. Handles retrieving vacant ports,
+    and also guarantees returned ports are not used by others by adding it to,
+    and maintaining a container of reserved ports. The internal container is
+    a list of ports stored by socket types and family:
+
+    ports[socktype][family] = [<ports>]
+
+    The ports stored here represent ports that are considered unavailable and
+    should not be bound to.
+    """
+
+    def __init__(self):
+        """Create a reserved ports container."""
+        self.reserved_ports = {}
+
+    def reserve(self, ports, socktype=SOCK_STREAM, family=AF_INET):
+        """Mark the given ports as reserved. Meaning that even if the operating
+        system has these ports as free they'll be considered unusable if set as
+        reserved.
+
+        Keyword Arguments:
+        ports - A list of ports to reserve
+        socktype - socket types (SOCK_STREAM, SOCK_DGRAM, etc.)
+        family - protocol family (AF_INET, AF_INET6, etc.)
+        """
+
+        if isinstance(ports, int):
+            ports = [ports]
+        if not isinstance(ports, list):
+            ports = list(ports)
+
+        if socktype not in self.reserved_ports:
+            self.reserved_ports[socktype] = {}
+
+        if family not in self.reserved_ports[socktype]:
+            self.reserved_ports[socktype][family] = ports
+        else:
+            self.reserved_ports[socktype][family].extend(ports)
+
+    def is_reserved(self, port, socktype=SOCK_STREAM, family=AF_INET):
+        """Check to see if a given port is reserved.
+
+        Keyword Arguments:
+        port - The port to check
+        socktype - socket types (SOCK_STREAM, SOCK_DGRAM, etc.)
+        family - protocol family (AF_INET, AF_INET6, etc.)
+
+        Return:
+        True if the port is reserved, False otherwise
+        """
+
+        try:
+            if (port < MIN_PORT or
+                    port in self.reserved_ports[socktype][family]):
+
+                LOGGER.debug("{0}/{1} port '{2}' is reserved".format(
+                    socket_type(socktype), socket_family(family), port))
+                return True
+        except:
+            pass
+
+        return False
+
+    def get_avail(self, host='', ports=0, socktype=SOCK_STREAM, family=AF_INET,
+                  attempts=PORT_ATTEMPTS):
+        """Retrieve available ports. Ports are considered available if they
+        have not been reserved and are currently not in use by something else.
+
+        If a given port is zero this function retrieves an available port from
+        the operating system. If non-zero it checks to make sure the port is
+        currently unused by the operating system.
+
+        Once retrieved or validated it makes sure the port has not already been
+        reserved. If it is reserved and port was initially zero it tries again
+        up to 'attempts' tries. Otherwise if it was non-zero an exception is
+        thrown.
+
+        Keyword Arguments:
+        host - host address
+        port - port number(s) to check availability
+        socktype - socket types (SOCK_STREAM, SOCK_DGRAM, etc.)
+        family - protocol family (AF_INET, AF_INET6, etc.)
+        attempts - The number of attempts to try and find a free port
+
+        Return:
+        Available ports as a list, otherwise a PortError exception is thrown
+        if none are available.
+        """
+
+        if isinstance(ports, int):
+            ports = [ports]
+        if not isinstance(ports, list):
+            ports = list(ports)
+
+        res = []
+        for port in ports:
+            for attempt in range(attempts):
+                p = get_unused_os_port(host, port, socktype, family)
+                if p != 0 and not self.is_reserved(p, socktype, family):
+                    res.append(p)
+                    break
+
+                if port:
+                    raise PortError(socktype, family, port)
+            else:
+                raise PortError(socktype, family, attempts=attempts)
+
+        return res
+
+    def get_range(self, host='', port=0, socktype=SOCK_STREAM,
+                  family=AF_INET, num=1, attempts=PORT_ATTEMPTS):
+        """Retrieve a range of ports starting at port.
+
+        Keyword Arguments:
+        host - host address
+        port - port number to get and/or check availability
+        socktype - socket types (SOCK_STREAM, SOCK_DGRAM, etc.)
+        family - protocol family (AF_INET, AF_INET6, etc.)
+        num - the number of ports to return including a given port
+        attempts - The number of attempts to try and find a free port
+
+        Return:
+        A list of available port(s). If none are available then a PortError
+        exception is thrown.
+        """
+
+        num = num or 1
+
+        for attempt in range(attempts):
+            step = 1 if num > 0 else -1
+            if port != 0:
+                return self.get_avail(
+                    host, range(port, port + num, step),
+                    socktype, family, attempts)
+
+            # Need a random port first
+            port = self.get_avail(host, 0, socktype, family)
+            ports = self.get_avail(
+                host, range(port[0] + step, port[0] + num, step),
+                socktype, family, attempts)
+            if ports:
+                return port + ports
+
+        raise PortError(socktype, family, attempts=attempts)
+
+    def get_range_and_reserve(self, host='', port=0, socktype=SOCK_STREAM,
+                              family=AF_INET, num=0, attempts=PORT_ATTEMPTS):
+        """Retrieve available port and port +/- num and reserve them.
+
+        If any ports are returned by calling 'get_range' those ports are then
+        stored as 'reserved'.
+
+        Keyword Arguments:
+        host - host address
+        port - port number to check availability
+        socktype - socket types (SOCK_STREAM, SOCK_DGRAM, etc.)
+        family - protocol family (AF_INET, AF_INET6, etc.)
+        num - the number of ports to num +/- port
+        attempts - The number of attempts to try and find a free port
+
+        Return:
+        If available, return the port +/- num ports, otherwise return an
+        empty list.
+        """
+
+        res = self.get_range(host, port, socktype, family, num, attempts)
+        self.reserve(res, socktype, family)
+        return res
+
+
+PORTS = Ports()
+
+
+def get_available_port(host='', socktype=0, family=0, num=0,
+                       port=0, config=None):
+    """Retrieve the primary available port, and reserve it and its offsets.
+
+    The majority of use cases probably involve the need to reserve multiple
+    ports (primary plus some offsets), but only retrieve the primary port.
+    This function does that.
+
+    This function purposely throws an exception if it cannot locate a
+    singular port.
+
+    Note:
+    This is a wrapper/convenience function. See the
+    Ports.get_range_and_reserve method for more functional details.
+
+    Result:
+    The singular primary available port.
+    """
+
+    if config:
+        host = host or config.get('-i')
+        socktype = SOCK_STREAM if '-t' in config else SOCK_DGRAM
+
+    # The family (without a host) and socktype should really always be
+    # specified when calling this function, but just in case use some defaults
+    if family < 1:
+        if host:
+            family = AF_INET6 if ':' in host else AF_INET
+        else:
+            family = AF_INET
+
+    if socktype < 1:
+        socktype = SOCK_DGRAM  # Most tests are UDP based
+    available = PORTS.get_range_and_reserve(
+        host, port, socktype, family, num)
+    # If we don't have a valid socktype and family badness happens
+    return available[0]

-- 
To view, visit https://gerrit.asterisk.org/8746
To unsubscribe, visit https://gerrit.asterisk.org/settings

Gerrit-Project: testsuite
Gerrit-Branch: 14
Gerrit-MessageType: merged
Gerrit-Change-Id: I3da461123afc30e1f5ca12e65d289eaa42d6de00
Gerrit-Change-Number: 8746
Gerrit-PatchSet: 4
Gerrit-Owner: Kevin Harwell <kharwell at digium.com>
Gerrit-Reviewer: Benjamin Keith Ford <bford at digium.com>
Gerrit-Reviewer: Corey Farrell <git at cfware.com>
Gerrit-Reviewer: Jenkins2
Gerrit-Reviewer: Joshua Colp <jcolp at digium.com>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.digium.com/pipermail/asterisk-code-review/attachments/20180423/addbd274/attachment-0001.html>


More information about the asterisk-code-review mailing list