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

Kevin Harwell asteriskteam at digium.com
Mon Apr 9 12:38:32 CDT 2018


Kevin Harwell has uploaded this change for review. ( 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
---
M lib/python/asterisk/sipp.py
A lib/python/asterisk/utils_socket.py
2 files changed, 457 insertions(+), 0 deletions(-)



  git pull ssh://gerrit.asterisk.org:29418/testsuite refs/changes/46/8746/1

diff --git a/lib/python/asterisk/sipp.py b/lib/python/asterisk/sipp.py
index acdb20d..beeb5e6 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,18 @@
             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 also check that that port + 4 is also available
+            # just in case.
+            default_args['-mp'] = str(get_available_port(
+                config=default_args, span=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 100755
index 0000000..086dbc0
--- /dev/null
+++ b/lib/python/asterisk/utils_socket.py
@@ -0,0 +1,444 @@
+#!/usr/bin/env python
+"""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
+import sys
+import unittest
+
+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 = 1000
+
+
+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, attempts=PORT_ATTEMPTS):
+        """Create a port error"""
+        msg = "Unable to find 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 not isinstance(ports, list):
+            ports = [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 ports are available they're returned, otherwise an empty list is
+        returned.
+
+        If a given port is zero then this function retrieves an available
+        port from the operating system. If a port is non-zero, and not
+        reserved then it makes sure nothing else is currently using it.
+
+        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:
+        If available, return the ports as, otherwise return and empty list.
+        """
+
+        if not isinstance(ports, list):
+            ports = [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: return []
+            else:
+                raise PortError(socktype, family, attempts)
+
+        return res
+
+    def get_spanned(self, host='', port=0, socktype=SOCK_STREAM,
+                    family=AF_INET, span=0, attempts=PORT_ATTEMPTS):
+        """Retrieve available port and port +/- span.
+
+        If span is non-zero then port +/- span ports are retrieved and returned.
+        All ports must be available for this to return success. If any port or
+        port +/- span is not available then this returns an empty list.
+
+        If span is zero then just port is checked and/or retrieved.
+
+        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:
+        If available, return the port +/- span ports, otherwise return an
+        empty list.
+        """
+
+        for attempt in range(attempts):
+            step = 1 if span > 0 else -1
+            if port != 0:
+                return self.get_avail(host, range(port, port + span + step,
+                    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] + 1, port[0] + span +
+                step, step), socktype, family, attempts)
+            if ports:
+                return port + ports
+
+        PortError(socktype, family, attempts)
+
+    def get_spanned_and_reserve(self, host='', port=0, socktype=SOCK_STREAM,
+                                family=AF_INET, span=0, attempts=PORT_ATTEMPTS):
+        """Retrieve available port and port +/- span and reserve them.
+
+        If any ports are returned by calling 'get_spanned' those ports are then
+        stored as 'reserved'.
+
+        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:
+        If available, return the port +/- span ports, otherwise return an
+        empty list.
+        """
+
+        res = self.get_spanned(host, port, socktype, family, span, attempts)
+        self.reserve(res, socktype, family)
+        return res
+
+
+PORTS = Ports()
+
+
+def get_available_port(host='', socktype=0, family=0, span=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_spanned_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_spanned_and_reserve(
+        host, port, socktype, family, span)
+    # If we don't have a valid socktype and family badness happens
+    return available[0]
+
+
+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]
+
+        reserve = lambda h, s, f: self.ports.reserve(p, s, f)
+        self._on_socktype(None, reserve)
+
+        test = lambda 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
+
+        reserve = lambda h, s, f: self.ports.reserve(p, s, f)
+        self._on_socktype(None, reserve)
+
+        self.assertTrue(self.ports.is_reserved(MIN_PORT - 1))
+
+        test = lambda h, s, f: self.assertTrue(
+            self.ports.is_reserved(p, s, f))
+        self._on_socktype(None, test)
+
+        test = lambda h, s, f: self.assertFalse(
+            self.ports.is_reserved(71234, s, f))
+        self._on_socktype(None, test)
+
+    def test_003_get_avail(self):
+        """Test retrieving a ports by families and types"""
+
+        p = [0, 0]
+        test = lambda h, s, f: self.assertNotEqual(
+            self.ports.get_avail(h, p, s, f), p)
+        self._on_host(test)
+
+        p = [51234, 51235]
+        test = lambda h, s, f: self.assertEqual(
+            self.ports.get_avail(h, p, s, f), p)
+        self._on_host(test)
+
+        reserve = lambda h, s, f: self.ports.reserve(p, s, f)
+        self._on_socktype(None, reserve)
+
+        test = lambda h, s, f: self.assertEqual(
+            self.ports.get_avail(h, p, s, f), [])
+        self._on_host(test)
+
+    def test_004_get_spanned(self):
+        """Test retrieving a spanned ports by families and types"""
+        p = 0
+        test = lambda h, s, f: self.assertEqual(len(
+            self.ports.get_spanned(h, p, s, f, 2)), 3)
+        self._on_host(test)
+
+        p = 5000
+        test = lambda h, s, f: self.assertEqual(
+            self.ports.get_spanned(h, p, s, f, 2), [5000, 5001, 5002])
+        self._on_host(test)
+
+        test = lambda h, s, f: self.assertEqual(
+            self.ports.get_spanned(h, p, s, f, -2), [5000, 4999, 4998])
+        self._on_host(test)
+
+    def test_005_get_spanned_and_reserve(self):
+        """Test retrieving and reserving spanned ports by families and types"""
+        p = 5000
+        get_spanned = (lambda h, s, f:
+                       self.ports.get_spanned_and_reserve(h, p, s, f, 2))
+        self._on_host(get_spanned)
+
+        test = lambda h, s, f: self.assertTrue(
+            self.ports.is_reserved(5000, s, f))
+        self._on_host(test)
+
+        test = lambda h, s, f: self.assertTrue(
+            self.ports.is_reserved(5001, s, f))
+        self._on_host(test)
+
+        test = lambda h, s, f: self.assertTrue(
+            self.ports.is_reserved(5002, s, f))
+        self._on_host(test)
+
+    def test_006_get_available_port(self):
+        """Test retrieving an available port"""
+        p = 5000
+
+        test = lambda 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 = 5001
+        self.assertEqual(get_available_port(
+            config={'-i': '0.0.0.0'}, span=2, port=p), p)
+        self.assertEqual(get_available_port(
+            config={'-i': '0.0.0.0',  '-t': ''}, span=2, port=p), p)
+        self.assertEqual(get_available_port(
+            config={'-i': '[::]'}, span=2, port=p), p)
+        self.assertEqual(get_available_port(
+            config={'-i': '[::]',  '-t': ''}, span=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()
+

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

Gerrit-Project: testsuite
Gerrit-Branch: 14
Gerrit-MessageType: newchange
Gerrit-Change-Id: I3da461123afc30e1f5ca12e65d289eaa42d6de00
Gerrit-Change-Number: 8746
Gerrit-PatchSet: 1
Gerrit-Owner: Kevin Harwell <kharwell at digium.com>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.digium.com/pipermail/asterisk-code-review/attachments/20180409/50159ba3/attachment-0001.html>


More information about the asterisk-code-review mailing list