diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py index b19a74f36..963c3083b 100755 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/standalone_authenticator.py @@ -1,8 +1,8 @@ """Standalone authenticator.""" import os +import psutil import signal import socket -import subprocess import sys import time @@ -275,30 +275,26 @@ class StandaloneAuthenticator(object): If so, also tell the user via a display notification. .. warning:: - The current implementation is Linux-specific. (On other - operating systems, it will simply not detect bound ports.) - This function can only usefully be run as root. + 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: - proc = subprocess.Popen( - [constants.NETSTAT, "-nta", "--program"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, _ = proc.communicate() - if proc.wait() != 0: - raise OSError("netstat subprocess failed") - lines = [x.split() for x in stdout.split("\n")[2:] if x] - listeners = [L[6] for L in lines if - # IPv4 socket case - (L[0] == 'tcp' and L[5] == 'LISTEN' \ - and L[3] == '0.0.0.0:{0}'.format(port)) or \ - # IPv6 socket case - (L[0] == 'tcp6' and L[5] == 'LISTEN' \ - and L[3] == ':::{0}'.format(port))] - if listeners: - pid, name = listeners[0].split("/") + 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 " @@ -306,11 +302,11 @@ class StandaloneAuthenticator(object): "that port. Please stop the {0} program temporarily " "and then try again.".format(name, pid, port)) return True - except (OSError, ValueError, IndexError): - # A sign that this command isn't available or usable this - # way on this operating system, or there was something - # unexpected about the format of the netstat output; we will - # not be able to recover from this condition. + 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/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py index 6811371df..b8e9baff9 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -1,6 +1,7 @@ """Tests for letsencrypt.client.standalone_authenticator.""" import os import pkg_resources +import psutil import signal import socket import unittest @@ -187,71 +188,101 @@ class AlreadyListeningTest(unittest.TestCase): StandaloneAuthenticator self.authenticator = StandaloneAuthenticator() - @mock.patch("letsencrypt.client.standalone_authenticator.subprocess.Popen") - def test_subprocess_fails(self, mock_popen): - subprocess_object = mock.MagicMock() - subprocess_object.communicate.return_value = ("foo", "bar") - subprocess_object.wait.return_value = 1 - mock_popen.return_value = subprocess_object - result = self.authenticator.already_listening(17) - self.assertFalse(result) - subprocess_object.wait.assert_called_once_with() - - @mock.patch("letsencrypt.client.standalone_authenticator.subprocess.Popen") - def test_no_relevant_line(self, mock_popen): - # pylint: disable=line-too-long,trailing-whitespace - subprocess_object = mock.MagicMock() - subprocess_object.communicate.return_value = ( - """Active Internet connections (servers and established) -Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name -tcp 0 0 127.0.1.1:53 0.0.0.0:* LISTEN 1234/foo -tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN 2345/bar -tcp 0 0 0.0.0.0:180 0.0.0.0:* LISTEN 11111/hello """, - "I am the standard error") - subprocess_object.wait.return_value = 0 - mock_popen.return_value = subprocess_object - result = self.authenticator.already_listening(17) - self.assertFalse(result) - - @mock.patch("letsencrypt.client.standalone_authenticator.subprocess.Popen") + @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + "net_connections") + @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") @mock.patch("letsencrypt.client.standalone_authenticator." "zope.component.getUtility") - def test_has_relevant_line(self, mock_get_utility, mock_popen): - # pylint: disable=line-too-long,trailing-whitespace - subprocess_object = mock.MagicMock() - subprocess_object.communicate.return_value = ( - """Active Internet connections (servers and established) -Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name -tcp 0 0 127.0.1.1:53 0.0.0.0:* LISTEN 1234/foo -tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN 2345/bar -tcp 0 0 0.0.0.0:17 0.0.0.0:* LISTEN 11111/hello -tcp 0 0 0.0.0.0:1728 0.0.0.0:* LISTEN 2345/bar """, - "I am the standard error") - subprocess_object.wait.return_value = 0 - mock_popen.return_value = subprocess_object + 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.client.standalone_authenticator.psutil." + "net_connections") + @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") + @mock.patch("letsencrypt.client.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.client.standalone_authenticator.psutil." + "net_connections") + @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") + @mock.patch("letsencrypt.client.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.client.standalone_authenticator.subprocess.Popen") + @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + "net_connections") + @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") @mock.patch("letsencrypt.client.standalone_authenticator." "zope.component.getUtility") - def test_has_relevant_ipv6_line(self, mock_get_utility, mock_popen): - # pylint: disable=line-too-long,trailing-whitespace - subprocess_object = mock.MagicMock() - subprocess_object.communicate.return_value = ( - """Active Internet connections (servers and established) -Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name -tcp 0 0 127.0.1.1:53 0.0.0.0:* LISTEN 1234/foo -tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN 2345/bar -tcp6 0 0 :::17 :::* LISTEN 11111/hello -tcp 0 0 0.0.0.0:1728 0.0.0.0:* LISTEN 2345/bar """, - "I am the standard error") - subprocess_object.wait.return_value = 0 - mock_popen.return_value = subprocess_object - result = self.authenticator.already_listening(17) + 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.""" diff --git a/setup.py b/setup.py index 5a5361a1c..1fc643304 100755 --- a/setup.py +++ b/setup.py @@ -26,6 +26,7 @@ install_requires = [ 'ConfArgParse', 'jsonschema', 'mock', + 'psutil>=2.1.0', # net_connections introduced in 2.1.0 'pycrypto', 'PyOpenSSL', 'python-augeas',