Merge pull request #249 from letsencrypt/use_psutil

Use psutil instead of netstat subprocess
This commit is contained in:
James Kasten 2015-02-25 19:02:00 -08:00
commit 1a00be0fdf
3 changed files with 108 additions and 80 deletions

View file

@ -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

View file

@ -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."""

View file

@ -26,6 +26,7 @@ install_requires = [
'ConfArgParse',
'jsonschema',
'mock',
'psutil>=2.1.0', # net_connections introduced in 2.1.0
'pycrypto',
'PyOpenSSL',
'python-augeas',