Merge remote-tracking branch 'github/letsencrypt/master' into renewer-cli

Conflicts:
	letsencrypt/client.py
This commit is contained in:
Jakub Warmuz
2015-06-11 21:16:47 +00:00
12 changed files with 175 additions and 47 deletions
+10 -5
View File
@@ -1,13 +1,18 @@
*.pyc *.pyc
*.egg-info *.egg-info/
.eggs/ .eggs/
build/ build/
dist/ dist/
venv/ /venv/
.tox/ /.tox/
# coverage
.coverage .coverage
m3 /htmlcov/
/.vagrant
# editor temporary files
*~ *~
.vagrant
*.swp *.swp
\#*# \#*#
+1 -1
View File
@@ -396,7 +396,7 @@ def main(args=sys.argv[1:]):
# Reporter # Reporter
report = reporter.Reporter() report = reporter.Reporter()
zope.component.provideUtility(report) zope.component.provideUtility(report)
atexit.register(report.print_messages) atexit.register(report.atexit_print_messages)
# Logging # Logging
level = -args.verbose_count * 10 level = -args.verbose_count * 10
+53 -1
View File
@@ -101,6 +101,29 @@ class Client(object):
raise errors.LetsEncryptClientError("Must agree to TOS") raise errors.LetsEncryptClientError("Must agree to TOS")
self.account.save() self.account.save()
self._report_new_account()
def _report_new_account(self):
"""Informs the user about their new Let's Encrypt account."""
reporter = zope.component.getUtility(interfaces.IReporter)
reporter.add_message(
"Your account credentials have been saved in your Let's Encrypt "
"configuration directory at {0}. You should make a secure backup "
"of this folder now. This configuration directory will also "
"contain certificates and private keys obtained by Let's Encrypt "
"so making regular backups of this folder is ideal.".format(
self.config.config_dir),
reporter.MEDIUM_PRIORITY, True)
assert self.account.recovery_token is not None
recovery_msg = ("If you lose your account credentials, you can recover "
"them using the token \"{0}\". You must write that down "
"and put it in a safe place.".format(
self.account.recovery_token))
if self.account.email is not None:
recovery_msg += (" Another recovery method will be e-mails sent to "
"{0}.".format(self.account.email))
reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True)
def obtain_certificate(self, domains, csr=None): def obtain_certificate(self, domains, csr=None):
"""Obtains a certificate from the ACME server. """Obtains a certificate from the ACME server.
@@ -204,9 +227,38 @@ class Client(object):
"Non-standard path(s), might not work with crontab installed " "Non-standard path(s), might not work with crontab installed "
"by your operating system package manager") "by your operating system package manager")
return storage.RenewableCert.new_lineage( lineage = storage.RenewableCert.new_lineage(
domains[0], cert, privkey, chain, params, config, cli_config) domains[0], cert, privkey, chain, params, config, cli_config)
self._report_renewal_status(lineage)
return lineage
def _report_renewal_status(self, cert):
# pylint: disable=no-self-use
"""Informs the user about automatic renewal and deployment.
:param cert: Newly issued certificate
:type cert: :class:`letsencrypt.storage.RenewableCert`
"""
if ("autorenew" not in cert.configuration
or cert.configuration.as_bool("autorenew")):
if ("autodeploy" not in cert.configuration or
cert.configuration.as_bool("autodeploy")):
msg = "Automatic renewal and deployment has "
else:
msg = "Automatic renewal but not automatic deployment has "
else:
if ("autodeploy" not in cert.configuration or
cert.configuration.as_bool("autodeploy")):
msg = "Automatic deployment but not automatic renewal has "
else:
msg = "Automatic renewal and deployment has not "
msg += ("been enabled for your certificate. These settings can be "
"configured in the directories under {0}.").format(
cert.configuration["renewal_configs_dir"])
reporter = zope.component.getUtility(interfaces.IReporter)
reporter.add_message(msg, reporter.LOW_PRIORITY, True)
def save_certificate(self, certr, cert_path, chain_path): def save_certificate(self, certr, cert_path, chain_path):
# pylint: disable=no-self-use # pylint: disable=no-self-use
+32 -32
View File
@@ -54,8 +54,25 @@ def check_permissions(filepath, mode, uid=0):
return stat.S_IMODE(file_stat.st_mode) == mode and file_stat.st_uid == uid return stat.S_IMODE(file_stat.st_mode) == mode and file_stat.st_uid == uid
def _safely_attempt_open(fname, mode):
file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode)
return os.fdopen(file_d, "w"), fname
def _unique_file(path, filename_pat, count, mode):
while True:
try:
return _safely_attempt_open(
os.path.join(path, filename_pat(count)), mode)
except OSError as err:
# "File exists," is okay, try a different name.
if err.errno != errno.EEXIST:
raise
count += 1
def unique_file(path, mode=0o777): def unique_file(path, mode=0o777):
"""Safely finds a unique file for writing only (by default). """Safely finds a unique file.
:param str path: path/filename.ext :param str path: path/filename.ext
:param int mode: File mode :param int mode: File mode
@@ -64,52 +81,35 @@ def unique_file(path, mode=0o777):
""" """
path, tail = os.path.split(path) path, tail = os.path.split(path)
count = 0 return _unique_file(
while True: path, filename_pat=(lambda count: "%04d_%s" % (count, tail)),
fname = os.path.join(path, "%04d_%s" % (count, tail)) count=0, mode=mode)
try:
file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode)
return os.fdopen(file_d, "w"), fname
except OSError as exception:
# "File exists," is okay, try a different name.
if exception.errno != errno.EEXIST:
raise
count += 1
def unique_lineage_name(path, filename, mode=0o777): def unique_lineage_name(path, filename, mode=0o777):
"""Safely finds a unique file for writing only (by default). Uses a """Safely finds a unique file using lineage convention.
file lineage convention.
:param str path: directory path :param str path: directory path
:param str filename: proposed filename :param str filename: proposed filename
:param int mode: file mode :param int mode: file mode
:returns: tuple of file object and file name (which may be modified from :returns: tuple of file object and file name (which may be modified
the requested one by appending digits to ensure uniqueness) from the requested one by appending digits to ensure uniqueness)
:raises OSError: if writing files fails for an unanticipated reason, :raises OSError: if writing files fails for an unanticipated reason,
such as a full disk or a lack of permission to write to specified such as a full disk or a lack of permission to write to
location. specified location.
""" """
fname = os.path.join(path, "%s.conf" % (filename))
try: try:
file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return _safely_attempt_open(
return os.fdopen(file_d, "w"), fname os.path.join(path, "%s.conf" % (filename)), mode=mode)
except OSError as err: except OSError as err:
if err.errno != errno.EEXIST: if err.errno != errno.EEXIST:
raise err raise
count = 1 return _unique_file(
while True: path, filename_pat=(lambda count: "%s-%04d.conf" % (filename, count)),
fname = os.path.join(path, "%s-%04d.conf" % (filename, count)) count=1, mode=mode)
try:
file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode)
return os.fdopen(file_d, "w"), fname
except OSError as err:
if err.errno != errno.EEXIST:
raise err
count += 1
def safely_remove(path): def safely_remove(path):
@@ -152,7 +152,6 @@ class StandaloneAuthenticator(common.Plugin):
:rtype: bool :rtype: bool
""" """
display = zope.component.getUtility(interfaces.IDisplay) display = zope.component.getUtility(interfaces.IDisplay)
start_time = time.time() start_time = time.time()
@@ -266,6 +265,7 @@ class StandaloneAuthenticator(common.Plugin):
signal.signal(signal.SIGUSR1, self.client_signal_handler) signal.signal(signal.SIGUSR1, self.client_signal_handler)
signal.signal(signal.SIGUSR2, self.client_signal_handler) signal.signal(signal.SIGUSR2, self.client_signal_handler)
sys.stdout.flush()
fork_result = os.fork() fork_result = os.fork()
Crypto.Random.atfork() Crypto.Random.atfork()
if fork_result: if fork_result:
@@ -317,6 +317,7 @@ class PerformTest(unittest.TestCase):
"""What happens if start_listener() returns True.""" """What happens if start_listener() returns True."""
self.authenticator.start_listener = mock.Mock() self.authenticator.start_listener = mock.Mock()
self.authenticator.start_listener.return_value = True self.authenticator.start_listener.return_value = True
self.authenticator.already_listening = mock.Mock(return_value=False)
result = self.authenticator.perform(self.achalls) result = self.authenticator.perform(self.achalls)
self.assertEqual(len(self.authenticator.tasks), 2) self.assertEqual(len(self.authenticator.tasks), 2)
self.assertTrue( self.assertTrue(
@@ -335,6 +336,7 @@ class PerformTest(unittest.TestCase):
"""What happens if start_listener() returns False.""" """What happens if start_listener() returns False."""
self.authenticator.start_listener = mock.Mock() self.authenticator.start_listener = mock.Mock()
self.authenticator.start_listener.return_value = False self.authenticator.start_listener.return_value = False
self.authenticator.already_listening = mock.Mock(return_value=False)
result = self.authenticator.perform(self.achalls) result = self.authenticator.perform(self.achalls)
self.assertEqual(len(self.authenticator.tasks), 2) self.assertEqual(len(self.authenticator.tasks), 2)
self.assertTrue( self.assertTrue(
+16 -3
View File
@@ -1,5 +1,7 @@
"""Collects and displays information to the user.""" """Collects and displays information to the user."""
import collections import collections
import logging
import os
import Queue import Queue
import sys import sys
import textwrap import textwrap
@@ -46,20 +48,31 @@ class Reporter(object):
""" """
assert self.HIGH_PRIORITY <= priority <= self.LOW_PRIORITY assert self.HIGH_PRIORITY <= priority <= self.LOW_PRIORITY
self.messages.put(self._msg_type(priority, msg, on_crash)) self.messages.put(self._msg_type(priority, msg, on_crash))
logging.info("Reporting to user: %s", msg)
def atexit_print_messages(self, pid=os.getpid()):
"""Function to be registered with atexit to print messages.
:param int pid: Process ID
"""
# This ensures that messages are only printed from the process that
# created the Reporter.
if pid == os.getpid():
self.print_messages()
def print_messages(self): def print_messages(self):
"""Prints messages to the user and clears the message queue. """Prints messages to the user and clears the message queue.
If there is an unhandled exception, only messages for which If there is an unhandled exception, only messages for which
``on_crash`` is ``True`` are printed. ``on_crash`` is ``True`` are printed.
"""
"""
bold_on = False bold_on = False
if not self.messages.empty(): if not self.messages.empty():
no_exception = sys.exc_info()[0] is None no_exception = sys.exc_info()[0] is None
bold_on = sys.stdout.isatty() bold_on = sys.stdout.isatty()
if bold_on: if bold_on:
sys.stdout.write(self._BOLD) print self._BOLD
print 'IMPORTANT NOTES:' print 'IMPORTANT NOTES:'
wrapper = textwrap.TextWrapper(initial_indent=' - ', wrapper = textwrap.TextWrapper(initial_indent=' - ',
subsequent_indent=(' ' * 3)) subsequent_indent=(' ' * 3))
+1 -2
View File
@@ -125,10 +125,9 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True):
if combos: if combos:
authz_kwargs.update({"combinations": gen_combos(challbs)}) authz_kwargs.update({"combinations": gen_combos(challbs)})
if authz_status == messages2.STATUS_VALID: if authz_status == messages2.STATUS_VALID:
now = datetime.datetime.now()
authz_kwargs.update({ authz_kwargs.update({
"status": authz_status, "status": authz_status,
"expires": datetime.datetime(now.year, now.month + 1, now.day), "expires": datetime.datetime.now() + datetime.timedelta(days=31),
}) })
else: else:
authz_kwargs.update({ authz_kwargs.update({
+45
View File
@@ -5,6 +5,7 @@ import pkg_resources
import shutil import shutil
import tempfile import tempfile
import configobj
import mock import mock
from letsencrypt import account from letsencrypt import account
@@ -35,6 +36,50 @@ class ClientTest(unittest.TestCase):
self.network2.Network.assert_called_once_with( self.network2.Network.assert_called_once_with(
mock.ANY, mock.ANY, verify_ssl=True) mock.ANY, mock.ANY, verify_ssl=True)
@mock.patch("letsencrypt.client.zope.component.getUtility")
def test_report_new_account(self, mock_zope):
# pylint: disable=protected-access
self.config.config_dir = "/usr/bin/coffee"
self.account.recovery_token = "ECCENTRIC INVISIBILITY RHINOCEROS"
self.account.email = "rhino@jungle.io"
self.client._report_new_account()
call_list = mock_zope().add_message.call_args_list
self.assertTrue(self.config.config_dir in call_list[0][0][0])
self.assertTrue(self.account.recovery_token in call_list[1][0][0])
self.assertTrue(self.account.email in call_list[1][0][0])
@mock.patch("letsencrypt.client.zope.component.getUtility")
def test_report_renewal_status(self, mock_zope):
# pylint: disable=protected-access
cert = mock.MagicMock()
cert.configuration = configobj.ConfigObj()
cert.configuration["renewal_configs_dir"] = "/etc/letsencrypt/configs"
cert.configuration["autorenew"] = "True"
cert.configuration["autodeploy"] = "True"
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal and deployment has been" in msg)
self.assertTrue(cert.configuration["renewal_configs_dir"] in msg)
cert.configuration["autorenew"] = "False"
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("deployment but not automatic renewal" in msg)
self.assertTrue(cert.configuration["renewal_configs_dir"] in msg)
cert.configuration["autodeploy"] = "False"
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal and deployment has not" in msg)
self.assertTrue(cert.configuration["renewal_configs_dir"] in msg)
cert.configuration["autorenew"] = "True"
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal but not automatic deployment" in msg)
self.assertTrue(cert.configuration["renewal_configs_dir"] in msg)
class DetermineAccountTest(unittest.TestCase): class DetermineAccountTest(unittest.TestCase):
"""Tests for letsencrypt.client.determine_authenticator.""" """Tests for letsencrypt.client.determine_authenticator."""
+4 -1
View File
@@ -8,6 +8,8 @@ import unittest
import mock import mock
from letsencrypt import errors
class MakeOrVerifyDirTest(unittest.TestCase): class MakeOrVerifyDirTest(unittest.TestCase):
"""Tests for letsencrypt.le_util.make_or_verify_dir. """Tests for letsencrypt.le_util.make_or_verify_dir.
@@ -42,7 +44,8 @@ class MakeOrVerifyDirTest(unittest.TestCase):
self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o400) self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o400)
def test_existing_wrong_mode_fails(self): def test_existing_wrong_mode_fails(self):
self.assertRaises(Exception, self._call, self.path, 0o600) self.assertRaises(
errors.LetsEncryptClientError, self._call, self.path, 0o600)
def test_reraises_os_error(self): def test_reraises_os_error(self):
with mock.patch.object(os, 'makedirs') as makedirs: with mock.patch.object(os, 'makedirs') as makedirs:
@@ -69,7 +69,7 @@ class ProofOfPossessionTest(unittest.TestCase):
def test_perform_no_input(self): def test_perform_no_input(self):
self.assertTrue(self.proof_of_pos.perform(self.achall).verify()) self.assertTrue(self.proof_of_pos.perform(self.achall).verify())
@mock.patch("letsencrypt.recovery_token.zope.component.getUtility") @mock.patch("letsencrypt.proof_of_possession.zope.component.getUtility")
def test_perform_with_input(self, mock_input): def test_perform_with_input(self, mock_input):
# Remove the matching certificate # Remove the matching certificate
self.installer.get_all_certs_keys.return_value.pop() self.installer.get_all_certs_keys.return_value.pop()
+9
View File
@@ -30,6 +30,15 @@ class ReporterTest(unittest.TestCase):
self.reporter.print_messages() self.reporter.print_messages()
self.assertEqual(sys.stdout.getvalue(), "") self.assertEqual(sys.stdout.getvalue(), "")
def test_atexit_print_messages(self):
self._add_messages()
self.reporter.atexit_print_messages()
output = sys.stdout.getvalue()
self.assertTrue("IMPORTANT NOTES:" in output)
self.assertTrue("High" in output)
self.assertTrue("Med" in output)
self.assertTrue("Low" in output)
def test_tty_successful_exit(self): def test_tty_successful_exit(self):
sys.stdout.isatty = lambda: True sys.stdout.isatty = lambda: True
self._successful_exit_common() self._successful_exit_common()