Revisions through running/testing

This commit is contained in:
James Kasten 2015-02-19 04:13:39 -08:00
parent 08fc0852d7
commit 6c8eb8be17
14 changed files with 257 additions and 86 deletions

View file

@ -942,9 +942,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return tuple([int(i) for i in matches[0].split('.')])
def __str__(self):
return "Apache version %s" % ".".join(self.get_version())
def more_info(self):
"""Human-readable string to help understand the module"""
return (
"Configures Apache to authenticate and install HTTPS.{0}"
"Server root: {root}{0}"
"Version: {version}".format(
os.linesep, root=self.parser.loc["root"],
version=".".join(str(i) for i in self.version))
)
###########################################################################
# Challenges Section

View file

@ -19,6 +19,7 @@ from letsencrypt.client import le_util
from letsencrypt.client import network
from letsencrypt.client import reverter
from letsencrypt.client import revoker
from letsencrypt.client import standalone_authenticator
from letsencrypt.client.apache import configurator
from letsencrypt.client.display import ops
@ -102,7 +103,8 @@ class Client(object):
cert_file, chain_file = self.save_certificate(
certificate_msg, self.config.cert_path, self.config.chain_path)
revoker.Revoker.store_cert_key(cert_file, self.authkey.file, False)
revoker.Revoker.store_cert_key(
cert_file, self.authkey.file, self.config)
return cert_file, chain_file
@ -350,16 +352,53 @@ def determine_authenticator(config):
:param config: Configuration.
:type config: :class:`letsencrypt.client.interfaces.IConfig`
:returns: Valid Authenticator object or None
"""
auths = []
try:
auths.append(configurator.ApacheConfigurator(config))
except errors.LetsEncryptNoInstallationError:
logging.info("Unable to determine a way to authenticate the server")
if len(auths) > 1:
return ops.choose_authenticator(auths)
elif len(auths) == 1:
return auths[0]
# list of (Description, Known Authenticator classes, init arguments)
all_auths = [
("Apache Web Server", configurator.ApacheConfigurator, config),
("Standalone Authenticator",
standalone_authenticator.StandaloneAuthenticator),
]
# Available Authenticator objects
avail_auths = []
# Error messages for misconfigured authenticators
errs = {}
for pot_auth in all_auths:
try:
# I do not think this a great solution but haven't come up with
# anything better yet...
if len(pot_auth) == 2:
# pylint: disable=no-value-for-parameter
avail_auths.append((pot_auth[0], pot_auth[1]()))
elif len(pot_auth) == 3:
avail_auths.append((pot_auth[0], pot_auth[1](pot_auth[2])))
else:
errors.LetsEncryptClientError(
"IAuthenticator: Number of parameters not supported")
except errors.LetsEncryptMisconfigurationError as err:
errs[pot_auth[1]] = err
avail_auths.append((pot_auth[0], pot_auth[1]))
except errors.LetsEncryptNoInstallationError:
pass
if len(avail_auths) > 1:
auth = ops.choose_authenticator(avail_auths, errs)
elif len(avail_auths) == 1:
auth = avail_auths[0]
else:
auth = None
if auth in errs:
logging.error("Please fix the configuration for the Authenticator. "
"The following error message was received: "
"%s", errs[auth])
sys.exit(1)
return auth
def determine_installer(config):

View file

@ -11,24 +11,38 @@ from letsencrypt.client.display import util as display_util
util = zope.component.getUtility # pylint: disable=invalid-name
def choose_authenticator(auths):
def choose_authenticator(auths, errs):
"""Allow the user to choose their authenticator.
:param list auths: Where each is a
:class:`letsencrypt.client.interfaces.IAuthenticator` object
:param list auths: Where each is a tuple of the form
('description', 'IAuthenticator') where IAuthenticator is a
:class:`letsencrypt.client.interfaces.IAuthenticator` object or class
:param dict errs: Mapping IAuthenticator objects to error messages
:returns: Authenticator selected
:rtype: :class:`letsencrypt.client.interfaces.IAuthenticator`
"""
code, index = util(interfaces.IDisplay).menu(
"How would you like to authenticate with the Let's Encrypt CA?",
[str(auth) for auth in auths])
descs = [auth[0] for auth in auths]
iauths = [auth[1] for auth in auths]
while True:
code, index = util(interfaces.IDisplay).menu(
"How would you like to authenticate with the Let's Encrypt CA?",
descs, help_label="More Info")
if code == display_util.OK:
return iauths[index]
elif code == display_util.HELP:
if iauths[index] in errs:
msg = "Reported Error: %s" % errs[iauths[index]]
else:
msg = iauths[index].more_info()
util(interfaces.IDisplay).notification(
msg, height=display_util.HEIGHT)
else:
sys.exit(0)
if code == display_util.OK:
return auths[index]
else:
sys.exit(0)
def choose_names(installer):
"""Display screen to select domains to validate.
@ -116,7 +130,7 @@ def _gen_https_names(domains):
return "https://{dom[0]} and https://{dom[1]}".format(dom=domains)
elif len(domains) > 2:
return "{0}{1}{2}".format(
", ".join("https://" + dom for dom in domains[:-1]),
", ".join("https://%s" % dom for dom in domains[:-1]),
", and https://",
domains[-1])

View file

@ -20,10 +20,8 @@ def choose_certs(certs):
"""
while True:
code, selection = _display_certs(certs)
if code == display_util.OK:
if confirm_revocation(certs[selection]):
return selection
return selection
elif code == display_util.HELP:
more_info_cert(certs[selection])
else:

View file

@ -93,10 +93,10 @@ class NcursesDisplay(object):
help_button=help_button, help_label=help_label,
width=self.width, height=self.height)
if code == OK:
return code, int(tag) - 1
if code == CANCEL:
return code, -1
return code, -1
return code, int(tag) - 1
def input(self, message):
"""Display an input box to the user.
@ -108,7 +108,7 @@ class NcursesDisplay(object):
`string` - input entered by the user
"""
return self.dialog.inputbox(message)
return self.dialog.inputbox(message, width=self.width)
def yesno(self, message, yes_label="Yes", no_label="No"):
"""Display a Yes/No dialog box
@ -232,12 +232,19 @@ class FileDisplay(object):
self.outfile.write("{0}{frame}{msg}{0}{frame}".format(
os.linesep, frame=side_frame, msg=message))
ans = raw_input("{yes}/{no}: ".format(
yes=_parens_around_char(yes_label),
no=_parens_around_char(no_label)))
while True:
ans = raw_input("{yes}/{no}: ".format(
yes=_parens_around_char(yes_label),
no=_parens_around_char(no_label)))
return (ans.startswith(yes_label[0].lower()) or
ans.startswith(yes_label[0].upper()))
# Couldn't get pylint indentation right with elif
# elif doesn't matter in this situation
if (ans.startswith(yes_label[0].lower()) or
ans.startswith(yes_label[0].upper())):
return True
if (ans.startswith(no_label[0].lower()) or
ans.startswith(no_label[0].upper())):
return False
def checklist(self, message, tags):
"""Display a checklist.
@ -275,7 +282,7 @@ class FileDisplay(object):
:param list indices: input
:param list tags: Original tags of the checklist
:returns: tags the user selected
:returns: valid tags the user selected
:rtype: :class:`list` of :class:`str`
"""
@ -387,7 +394,8 @@ def separate_list_input(input_):
"""
no_commas = input_.replace(",", " ")
return [string for string in no_commas.split()]
# Each string is naturally unicode, this causes problems with M2Crypto SANs
return [str(string) for string in no_commas.split()]
def _parens_around_char(label):
"""Place parens around first character of label.

View file

@ -57,6 +57,13 @@ class IAuthenticator(zope.interface.Interface):
"""
def more_info(self):
"""Human-readable string to help the user.
Should describe the steps taken and any relevant info to help the user
decide which Authenticator to use.
"""
class IConfig(zope.interface.Interface):
"""Let's Encrypt user-supplied configuration.

View file

@ -105,19 +105,19 @@ class Revoker(object):
if certs:
selection = revocation.choose_certs(certs)
self._safe_revoke([certs[selection]])
# This is safer than using remove as Revoker.Certs only check
# the DER value of the cert. There could potentially be multiple
# backup certs with the same value.
del certs[selection]
revoked_certs = self._safe_revoke([certs[selection]])
# Since we are currently only revoking one cert at a time...
if revoked_certs:
# This is safer than using remove as Revoker.Certs only
# check the DER value of the cert. There could potentially
# be multiple backup certs with the same value.
del certs[selection]
else:
logging.info(
"There are not any trusted Let's Encrypt "
"certificates for this server.")
return
def _populate_saved_certs(self, csha1_vhlist):
# pylint: disable=no-self-use
"""Populate a list of all the saved certs.
@ -174,6 +174,9 @@ class Revoker(object):
:param certs: certs intended to be revoked
:type certs: :class:`list` of :class:`letsencrypt.client.revoker.Cert`
:returns: certs successfully revoked
:rtype: :class:`list` of :class:`letsencrypt.client.revoker.Cert`
"""
success_list = []
try:
@ -192,6 +195,8 @@ class Revoker(object):
if success_list:
self._remove_certs_keys(success_list)
return success_list
def _acme_revoke(self, cert):
"""Revoke the certificate with the ACME server.
@ -290,9 +295,9 @@ class Revoker(object):
cls._catalog_files(
config.cert_key_backup, cert_path, key_path, list_path)
@classmethod
def _catalog_files(cls, backup_dir, cert_path, key_path, list_path):
idx = 0
if os.path.isfile(list_path):
with open(list_path, "r+b") as csvfile:
csvreader = csv.reader(csvfile)
@ -309,8 +314,8 @@ class Revoker(object):
with open(list_path, "wb") as csvfile:
csvwriter = csv.writer(csvfile)
# You must move the files before appending the row
cls._copy_files(backup_dir, "0", cert_path, key_path)
csvwriter.writerow(["0", cert_path, key_path])
cls._copy_files(backup_dir, idx, cert_path, key_path)
csvwriter.writerow([str(idx), cert_path, key_path])
@classmethod
def _copy_files(cls, backup_dir, idx, cert_path, key_path):
@ -481,8 +486,21 @@ class Cert(object):
text.append("Not Before: %s" % str(self.get_not_before()))
text.append("Not After: %s" % str(self.get_not_after()))
text.append("Serial Number: %s" % self.get_serial())
text.append("SHA1: %s" % self.get_fingerprint())
text.append("SHA1: %s%s" % (self.get_fingerprint(), os.linesep))
text.append("Installed: %s" % self.get_installed_msg())
if self.orig is not None:
if self.orig.status == "":
text.append("Path: %s" % self.orig.path)
else:
text.append("Orig Path: %s (%s)" % self.orig)
if self.orig_key is not None:
if self.orig_key.status == "":
text.append("Auth Key Path: %s" % self.orig_key.path)
else:
text.append("Orig Auth Key Path: %s (%s)" % self.orig_key)
text.append("")
return os.linesep.join(text)
def pretty_print(self):

View file

@ -149,14 +149,14 @@ class StandaloneAuthenticator(object):
if self.subproc_state == "ready":
return True
elif self.subproc_state == "inuse":
display.generic_notification(
display.notification(
"Could not bind TCP port {0} because it is already in "
"use by another process on this system (such as a web "
"server). Please stop the program in question and then "
"try again.".format(port))
return False
elif self.subproc_state == "cantbind":
display.generic_notification(
display.notification(
"Could not bind TCP port {0} because you don't have "
"the appropriate permissions (for example, you "
"aren't running this program as "
@ -164,7 +164,7 @@ class StandaloneAuthenticator(object):
return False
time.sleep(0.1)
display.generic_notification(
display.notification(
"Subprocess unexpectedly timed out while trying to bind TCP "
"port {0}.".format(port))
@ -291,7 +291,7 @@ class StandaloneAuthenticator(object):
if listeners:
pid, name = listeners[0].split("/")
display = zope.component.getUtility(interfaces.IDisplay)
display.generic_notification(
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 "
@ -406,3 +406,12 @@ class StandaloneAuthenticator(object):
# TODO: restore original signal handlers in parent process
# by resetting their actions to SIG_DFL
# print "TCP listener subprocess has been told to shut down"
def more_info(self): # pylint: disable=no-self-use
"""Human-readable string that describes the Authenticator."""
return ("The Standalone Authenticator uses PyOpenSSL to listen "
"on port 443 and perform DVSNI challenges. Once a certificate"
"is attained, it will be saved in the "
"(TODO) current working directory.{0}{0}"
"Port 443 must be open in order to use the "
"Standalone Authenticator.")

View file

@ -12,25 +12,49 @@ class ChooseAuthenticatorTest(unittest.TestCase):
"""Test choose_authenticator function."""
def setUp(self):
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
self.mock_apache = mock.Mock()
self.mock_stand = mock.Mock()
self.mock_apache().more_info.return_value = "Apache Info"
self.mock_stand().more_info.return_value = "Standalone Info"
self.auths = [
("Apache Tag", self.mock_apache),
("Standalone Tag", self.mock_stand)
]
self.errs = {self.mock_apache: "This is an error message."}
@classmethod
def _call(cls, auths):
def _call(cls, auths, errs):
from letsencrypt.client.display.ops import choose_authenticator
return choose_authenticator(auths)
return choose_authenticator(auths, errs)
@mock.patch("letsencrypt.client.display.ops.util")
def test_successful_choice(self, mock_util):
mock_util().menu.return_value = (display_util.OK, 0)
ret = self._call(["authenticator1", "auth2"])
ret = self._call(self.auths, {})
self.assertEqual(ret, "authenticator1")
self.assertEqual(ret, self.mock_apache)
@mock.patch("letsencrypt.client.display.ops.util")
def test_more_info(self, mock_util):
mock_util().menu.side_effect = [
(display_util.HELP, 0),
(display_util.HELP, 1),
(display_util.OK, 1),
]
ret = self._call(self.auths, self.errs)
self.assertEqual(mock_util().notification.call_count, 2)
self.assertEqual(ret, self.mock_stand)
@mock.patch("letsencrypt.client.display.ops.util")
def test_no_choice(self, mock_util):
mock_util().menu.return_value = (display_util.CANCEL, 0)
self.assertRaises(SystemExit, self._call, ["authenticator1"])
self.assertRaises(SystemExit, self._call, self.auths, {})
class GenHttpsNamesTest(unittest.TestCase):

View file

@ -29,8 +29,7 @@ class ChooseCertsTest(unittest.TestCase):
return choose_certs(certs)
@mock.patch("letsencrypt.client.display.revocation.util")
def test_confirm_revocation(self, mock_util):
mock_util().yesno.return_value = True
def test_revocation(self, mock_util):
mock_util().menu.return_value = (display_util.OK, 0)
choice = self._call(self.certs)
@ -38,12 +37,8 @@ class ChooseCertsTest(unittest.TestCase):
self.assertTrue(self.certs[choice] == self.cert0)
@mock.patch("letsencrypt.client.display.revocation.util")
def test_confirm_cancel(self, mock_util):
mock_util().yesno.return_value = False
mock_util().menu.side_effect = [
(display_util.OK, 0),
(display_util.CANCEL, -1)
]
def test_cancel(self, mock_util):
mock_util().menu.return_value = (display_util.CANCEL, -1)
self.assertRaises(SystemExit, self._call, self.certs)
@ -53,7 +48,6 @@ class ChooseCertsTest(unittest.TestCase):
(display_util.HELP, 1),
(display_util.OK, 1),
]
mock_util().yesno.return_value = True
choice = self._call(self.certs)
@ -79,5 +73,25 @@ class SuccessRevocationTest(unittest.TestCase):
self.assertEqual(mock_util().notification.call_count, 1)
class ConfirmRevocationTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.revoker import Cert
self.cert = Cert(pkg_resources.resource_filename(
"letsencrypt.client.tests", os.path.join("testdata", "cert.pem")))
@classmethod
def _call(cls, cert):
from letsencrypt.client.display.revocation import confirm_revocation
return confirm_revocation(cert)
@mock.patch("letsencrypt.client.display.revocation.util")
def test_confirm_revocation(self, mock_util):
mock_util().yesno.return_value = True
self.assertTrue(self._call(self.cert))
mock_util().yesno.return_value = False
self.assertFalse(self._call(self.cert))
if __name__ == "__main__":
unittest.main()

View file

@ -91,6 +91,14 @@ class NcursesDisplayTest(DisplayT):
self.assertEqual(ret, (display_util.OK, 0))
@mock.patch("letsencrypt.client.display.util.dialog.Dialog.menu")
def test_menu_desc_only_help(self, mock_menu):
mock_menu.return_value = (display_util.HELP, "2")
ret = self.displayer.menu("Message", self.tags, help_label="More Info")
self.assertEqual(ret, (display_util.HELP, 1))
@mock.patch("letsencrypt.client.display.util.dialog.Dialog.menu")
def test_menu_desc_only_cancel(self, mock_menu):
mock_menu.return_value = (display_util.CANCEL, "")
@ -103,7 +111,7 @@ class NcursesDisplayTest(DisplayT):
"dialog.Dialog.inputbox")
def test_input(self, mock_input):
self.displayer.input("message")
mock_input.assert_called_with("message")
self.assertEqual(mock_input.call_count, 1)
@mock.patch("letsencrypt.client.display.util.dialog.Dialog.yesno")
def test_yesno(self, mock_yesno):
@ -182,8 +190,13 @@ class FileOutputDisplayTest(DisplayT):
self.assertTrue(self.displayer.yesno("message"))
with mock.patch("__builtin__.raw_input", return_value="y"):
self.assertTrue(self.displayer.yesno("message"))
with mock.patch("__builtin__.raw_input", return_value="cancel"):
with mock.patch("__builtin__.raw_input", side_effect=["maybe", "y"]):
self.assertTrue(self.displayer.yesno("message"))
with mock.patch("__builtin__.raw_input", return_value="No"):
self.assertFalse(self.displayer.yesno("message"))
with mock.patch("__builtin__.raw_input", side_effect=["cancel", "n"]):
self.assertFalse(self.displayer.yesno("message"))
with mock.patch("__builtin__.raw_input", return_value="a"):
self.assertTrue(self.displayer.yesno("msg", yes_label="Agree"))

View file

@ -309,11 +309,21 @@ class CertTest(unittest.TestCase):
self.assertEqual(self.certs[0].orig.status, "")
self.assertEqual(self.certs[0].orig_key.status, "")
def test_print(self):
"""Just make sure there aren't any errors."""
def test_print_meta(self):
"""Just make sure there aren't any major errors."""
self.certs[0].add_meta(
0, self.paths[0], self.key_path, self.paths[0], self.key_path)
# Changed path and deleted file
self.certs[1].add_meta(
1, self.paths[0], "/not/a/path", self.paths[1], self.key_path)
self.assertTrue(self.certs[0].pretty_print())
self.assertTrue(self.certs[1].pretty_print())
def test_print_no_meta(self):
self.assertTrue(self.certs[0].pretty_print())
self.assertTrue(self.certs[1].pretty_print())
def create_revoker_certs():
"""Create a few revoker.Cert objects."""
from letsencrypt.client.revoker import Cert

View file

@ -542,5 +542,17 @@ class CleanupTest(unittest.TestCase):
self.assertRaises(ValueError, self.authenticator.cleanup, [chall])
class MoreInfoTest(unittest.TestCase):
"""Tests for more_info() method. (trivially)"""
def setUp(self):
from letsencrypt.client.standalone_authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator()
def test_chall_pref(self):
"""Make sure exceptions aren't raised."""
self.authenticator.more_info()
if __name__ == "__main__":
unittest.main()

View file

@ -16,7 +16,6 @@ import letsencrypt
from letsencrypt.client import configuration
from letsencrypt.client import client
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import le_util
from letsencrypt.client import log
@ -116,12 +115,14 @@ def main(): # pylint: disable=too-many-branches
displayer = display_util.NcursesDisplay()
else:
displayer = display_util.FileDisplay(sys.stdout)
zope.component.provideUtility(displayer)
if args.view_config_changes:
client.view_config_changes(config)
sys.exit()
# TODO: if revoke, rev_cert...
if args.revoke:
client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key)
sys.exit()
@ -135,32 +136,30 @@ def main(): # pylint: disable=too-many-branches
# Make sure we actually get an installer that is functioning properly
# before we begin to try to use it.
try:
auth = client.determine_authenticator(config)
except errors.LetsEncryptMisconfigurationError as err:
logging.fatal("Please fix your configuration before proceeding.%s"
"The Authenticator exited with the following message: "
"%s", os.linesep, err)
sys.exit(1)
auth = client.determine_authenticator(config)
if auth is None:
logging.critical("Unable to find a way to authenticate the server.")
sys.exit(4)
# Use the same object if possible
if interfaces.IInstaller.providedBy(auth): # pylint: disable=no-member
installer = auth
else:
installer = client.determine_installer(config)
# This is simple and avoids confusion right now.
installer = None
doms = ops.choose_names(installer) if args.domains is None else args.domains
# Prepare for init of Client
if args.privkey is None:
privkey = client.init_key(args.rsa_key_size, config.key_dir)
if args.authkey is None:
authkey = client.init_key(args.rsa_key_size, config.key_dir)
else:
privkey = le_util.Key(args.authkey[0], args.authkey[1])
authkey = le_util.Key(args.authkey[0], args.authkey[1])
acme = client.Client(config, privkey, auth, installer)
acme = client.Client(config, authkey, auth, installer)
# Validate the key and csr
client.validate_key_csr(privkey)
client.validate_key_csr(authkey)
# This more closely mimics the capabilities of the CLI
# It should be possible for reconfig only, install-only, no-install
@ -170,7 +169,7 @@ def main(): # pylint: disable=too-many-branches
if auth is not None:
cert_file, chain_file = acme.obtain_certificate(doms)
if installer is not None and cert_file is not None:
acme.deploy_certificate(doms, privkey, cert_file, chain_file)
acme.deploy_certificate(doms, authkey, cert_file, chain_file)
if installer is not None:
acme.enhance_config(doms, args.redirect)