diff --git a/docs/api/plugins/util.rst b/docs/api/plugins/util.rst new file mode 100644 index 000000000..6bc8995db --- /dev/null +++ b/docs/api/plugins/util.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.plugins.util` +------------------------------- + +.. automodule:: letsencrypt.plugins.util + :members: diff --git a/letsencrypt/plugins/standalone/authenticator.py b/letsencrypt/plugins/standalone/authenticator.py index 968063781..996f41cdc 100644 --- a/letsencrypt/plugins/standalone/authenticator.py +++ b/letsencrypt/plugins/standalone/authenticator.py @@ -1,6 +1,5 @@ """Standalone authenticator.""" import os -import psutil import signal import socket import sys @@ -289,47 +288,6 @@ class StandaloneAuthenticator(common.Plugin): # should terminate via sys.exit(). return self.do_child_process(port) - def already_listening(self, port): # pylint: disable=no-self-use - """Check if a process is already listening on the port. - - If so, also tell the user via a display notification. - - .. warning:: - On some operating systems, this function can only usefully be - run as root. - - :param int port: The TCP port in question. - :returns: True or False.""" - - listeners = [conn.pid for conn in psutil.net_connections() - if conn.status == 'LISTEN' and - conn.type == socket.SOCK_STREAM and - conn.laddr[1] == port] - try: - if listeners and listeners[0] is not None: - # conn.pid may be None if the current process doesn't have - # permission to identify the listening process! Additionally, - # listeners may have more than one element if separate - # sockets have bound the same port on separate interfaces. - # We currently only have UI to notify the user about one - # of them at a time. - pid = listeners[0] - name = psutil.Process(pid).name() - display = zope.component.getUtility(interfaces.IDisplay) - display.notification( - "The program {0} (process ID {1}) is already listening " - "on TCP port {2}. This will prevent us from binding to " - "that port. Please stop the {0} program temporarily " - "and then try again.".format(name, pid, port)) - return True - except (psutil.NoSuchProcess, psutil.AccessDenied): - # Perhaps the result of a race where the process could have - # exited or relinquished the port (NoSuchProcess), or the result - # of an OS policy where we're not allowed to look up the process - # name (AccessDenied). - pass - return False - # IAuthenticator method implementations follow def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use @@ -383,7 +341,7 @@ class StandaloneAuthenticator(common.Plugin): if not self.tasks: raise ValueError("nothing for .perform() to do") - if self.already_listening(self.config.dvsni_port): + if util.already_listening(self.config.dvsni_port): # If we know a process is already listening on this port, # tell the user, and don't even attempt to bind it. (This # test is Linux-specific and won't indicate that the port diff --git a/letsencrypt/plugins/standalone/tests/authenticator_test.py b/letsencrypt/plugins/standalone/tests/authenticator_test.py index 7ff2c03e1..955426533 100644 --- a/letsencrypt/plugins/standalone/tests/authenticator_test.py +++ b/letsencrypt/plugins/standalone/tests/authenticator_test.py @@ -187,109 +187,6 @@ class SubprocSignalHandlerTest(unittest.TestCase): mock_exit.assert_called_once_with(0) -class AlreadyListeningTest(unittest.TestCase): - """Tests for already_listening() method.""" - def setUp(self): - from letsencrypt.plugins.standalone.authenticator import \ - StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None) - - @mock.patch("letsencrypt.plugins.standalone.authenticator.psutil." - "net_connections") - @mock.patch("letsencrypt.plugins.standalone.authenticator.psutil.Process") - @mock.patch("letsencrypt.plugins.standalone.authenticator." - "zope.component.getUtility") - def test_race_condition(self, mock_get_utility, mock_process, mock_net): - # This tests a race condition, or permission problem, or OS - # incompatibility in which, for some reason, no process name can be - # found to match the identified listening PID. - from psutil._common import sconn - conns = [ - sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), - raddr=(), status="LISTEN", pid=None), - sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), - raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), - sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), - raddr=("::1", 111), status="CLOSE_WAIT", pid=None), - sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), - raddr=(), status="LISTEN", pid=4416)] - mock_net.return_value = conns - mock_process.side_effect = psutil.NoSuchProcess("No such PID") - # We simulate being unable to find the process name of PID 4416, - # which results in returning False. - self.assertFalse(self.authenticator.already_listening(17)) - self.assertEqual(mock_get_utility.generic_notification.call_count, 0) - mock_process.assert_called_once_with(4416) - - @mock.patch("letsencrypt.plugins.standalone.authenticator.psutil." - "net_connections") - @mock.patch("letsencrypt.plugins.standalone.authenticator.psutil.Process") - @mock.patch("letsencrypt.plugins.standalone.authenticator." - "zope.component.getUtility") - def test_not_listening(self, mock_get_utility, mock_process, mock_net): - from psutil._common import sconn - conns = [ - sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), - raddr=(), status="LISTEN", pid=None), - sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), - raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), - sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), - raddr=("::1", 111), status="CLOSE_WAIT", pid=None)] - mock_net.return_value = conns - mock_process.name.return_value = "inetd" - self.assertFalse(self.authenticator.already_listening(17)) - self.assertEqual(mock_get_utility.generic_notification.call_count, 0) - self.assertEqual(mock_process.call_count, 0) - - @mock.patch("letsencrypt.plugins.standalone.authenticator.psutil." - "net_connections") - @mock.patch("letsencrypt.plugins.standalone.authenticator.psutil.Process") - @mock.patch("letsencrypt.plugins.standalone.authenticator." - "zope.component.getUtility") - def test_listening_ipv4(self, mock_get_utility, mock_process, mock_net): - from psutil._common import sconn - conns = [ - sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), - raddr=(), status="LISTEN", pid=None), - sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), - raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), - sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), - raddr=("::1", 111), status="CLOSE_WAIT", pid=None), - sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), - raddr=(), status="LISTEN", pid=4416)] - mock_net.return_value = conns - mock_process.name.return_value = "inetd" - result = self.authenticator.already_listening(17) - self.assertTrue(result) - self.assertEqual(mock_get_utility.call_count, 1) - mock_process.assert_called_once_with(4416) - - @mock.patch("letsencrypt.plugins.standalone.authenticator.psutil." - "net_connections") - @mock.patch("letsencrypt.plugins.standalone.authenticator.psutil.Process") - @mock.patch("letsencrypt.plugins.standalone.authenticator." - "zope.component.getUtility") - def test_listening_ipv6(self, mock_get_utility, mock_process, mock_net): - from psutil._common import sconn - conns = [ - sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), - raddr=(), status="LISTEN", pid=None), - sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), - raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), - sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), - raddr=("::1", 111), status="CLOSE_WAIT", pid=None), - sconn(fd=3, family=10, type=1, laddr=("::", 12345), raddr=(), - status="LISTEN", pid=4420), - sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), - raddr=(), status="LISTEN", pid=4416)] - mock_net.return_value = conns - mock_process.name.return_value = "inetd" - result = self.authenticator.already_listening(12345) - self.assertTrue(result) - self.assertEqual(mock_get_utility.call_count, 1) - mock_process.assert_called_once_with(4420) - - class PerformTest(unittest.TestCase): """Tests for perform() method.""" def setUp(self): diff --git a/letsencrypt/plugins/util.py b/letsencrypt/plugins/util.py new file mode 100644 index 000000000..42c64b052 --- /dev/null +++ b/letsencrypt/plugins/util.py @@ -0,0 +1,49 @@ +"""Plugin utilities.""" +import socket + +import psutil +import zope.component + +from letsencrypt import interfaces + + +def already_listening(port): + """Check if a process is already listening on the port. + + If so, also tell the user via a display notification. + + .. warning:: + On some operating systems, this function can only usefully be + run as root. + + :param int port: The TCP port in question. + :returns: True or False.""" + + listeners = [conn.pid for conn in psutil.net_connections() + if conn.status == 'LISTEN' and + conn.type == socket.SOCK_STREAM and + conn.laddr[1] == port] + try: + if listeners and listeners[0] is not None: + # conn.pid may be None if the current process doesn't have + # permission to identify the listening process! Additionally, + # listeners may have more than one element if separate + # sockets have bound the same port on separate interfaces. + # We currently only have UI to notify the user about one + # of them at a time. + pid = listeners[0] + name = psutil.Process(pid).name() + display = zope.component.getUtility(interfaces.IDisplay) + display.notification( + "The program {0} (process ID {1}) is already listening " + "on TCP port {2}. This will prevent us from binding to " + "that port. Please stop the {0} program temporarily " + "and then try again.".format(name, pid, port)) + return True + except (psutil.NoSuchProcess, psutil.AccessDenied): + # Perhaps the result of a race where the process could have + # exited or relinquished the port (NoSuchProcess), or the result + # of an OS policy where we're not allowed to look up the process + # name (AccessDenied). + pass + return False diff --git a/letsencrypt/plugins/util_test.py b/letsencrypt/plugins/util_test.py new file mode 100644 index 000000000..14cbcf7a8 --- /dev/null +++ b/letsencrypt/plugins/util_test.py @@ -0,0 +1,103 @@ +"""Tests for letsencrypt.plugins.util.""" +import unittest + +import mock +import psutil + + +class AlreadyListeningTest(unittest.TestCase): + """Tests for letsencrypt.plugins.already_listening.""" + def _call(self, *args, **kwargs): + from letsencrypt.plugins.util import already_listening + return already_listening(*args, **kwargs) + + @mock.patch("letsencrypt.plugins.util.psutil.net_connections") + @mock.patch("letsencrypt.plugins.util.psutil.Process") + @mock.patch("letsencrypt.plugins.util.zope.component.getUtility") + def test_race_condition(self, mock_get_utility, mock_process, mock_net): + # This tests a race condition, or permission problem, or OS + # incompatibility in which, for some reason, no process name can be + # found to match the identified listening PID. + from psutil._common import sconn + conns = [ + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] + mock_net.return_value = conns + mock_process.side_effect = psutil.NoSuchProcess("No such PID") + # We simulate being unable to find the process name of PID 4416, + # which results in returning False. + self.assertFalse(self._call(17)) + self.assertEqual(mock_get_utility.generic_notification.call_count, 0) + mock_process.assert_called_once_with(4416) + + @mock.patch("letsencrypt.plugins.util.psutil.net_connections") + @mock.patch("letsencrypt.plugins.util.psutil.Process") + @mock.patch("letsencrypt.plugins.util.zope.component.getUtility") + def test_not_listening(self, mock_get_utility, mock_process, mock_net): + from psutil._common import sconn + conns = [ + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None)] + mock_net.return_value = conns + mock_process.name.return_value = "inetd" + self.assertFalse(self._call(17)) + self.assertEqual(mock_get_utility.generic_notification.call_count, 0) + self.assertEqual(mock_process.call_count, 0) + + @mock.patch("letsencrypt.plugins.util.psutil.net_connections") + @mock.patch("letsencrypt.plugins.util.psutil.Process") + @mock.patch("letsencrypt.plugins.util.zope.component.getUtility") + def test_listening_ipv4(self, mock_get_utility, mock_process, mock_net): + from psutil._common import sconn + conns = [ + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] + mock_net.return_value = conns + mock_process.name.return_value = "inetd" + result = self._call(17) + self.assertTrue(result) + self.assertEqual(mock_get_utility.call_count, 1) + mock_process.assert_called_once_with(4416) + + @mock.patch("letsencrypt.plugins.util.psutil.net_connections") + @mock.patch("letsencrypt.plugins.util.psutil.Process") + @mock.patch("letsencrypt.plugins.util.zope.component.getUtility") + def test_listening_ipv6(self, mock_get_utility, mock_process, mock_net): + from psutil._common import sconn + conns = [ + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=10, type=1, laddr=("::", 12345), raddr=(), + status="LISTEN", pid=4420), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] + mock_net.return_value = conns + mock_process.name.return_value = "inetd" + result = self._call(12345) + self.assertTrue(result) + self.assertEqual(mock_get_utility.call_count, 1) + mock_process.assert_called_once_with(4420) + + +if __name__ == "__main__": + unittest.main() # pragma: no cover