1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-21 19:01:07 +03:00

Merge pull request #376 from kuba/cli

Plugins discovery and new CLI with subcommands
This commit is contained in:
James Kasten
2015-05-10 08:09:10 -07:00
35 changed files with 1541 additions and 699 deletions

2
.gitignore vendored
View File

@@ -10,4 +10,4 @@ m3
*~
.vagrant
*.swp
\#*#
\#*#

View File

@@ -17,11 +17,15 @@ It's all automated:
* If domain control has been proven, a certificate will get issued and the tool
will automatically install it.
All you need to do is:
All you need to do is::
::
user@www:~$ sudo letsencrypt -d www.example.org auth
user@www:~$ sudo letsencrypt -d www.example.org
and if you have a compatbile web server (Apache), Let's Encrypt can
not only get a new certificate, but also deploy it and configure your
server automatically!::
user@www:~$ sudo letsencrypt -d www.example.org run
**Encrypt ALL the things!**

2
Vagrantfile vendored
View File

@@ -10,7 +10,7 @@ cd /vagrant
sudo ./bootstrap/ubuntu.sh
if [ ! -d "venv" ]; then
virtualenv --no-site-packages -p python2 venv
./venv/bin/python setup.py dev
./venv/bin/pip install -r requirements.txt -e .[dev,docs,testing]
fi
SETUP_SCRIPT

View File

@@ -0,0 +1,5 @@
:mod:`letsencrypt.client.plugins.common`
----------------------------------------
.. automodule:: letsencrypt.client.plugins.common
:members:

View File

@@ -0,0 +1,5 @@
:mod:`letsencrypt.client.plugins.disco`
---------------------------------------
.. automodule:: letsencrypt.client.plugins.disco
:members:

View File

@@ -15,7 +15,7 @@ Now you can install the development packages:
.. code-block:: shell
./venv/bin/python setup.py dev
./venv/bin/pip install -r requirements.txt -e .[dev,docs,testing]
The code base, including your pull requests, **must** have 100% test
statement coverage **and** be compliant with the :ref:`coding style
@@ -48,7 +48,7 @@ synced to ``/vagrant``, so you can get started with:
vagrant ssh
cd /vagrant
./venv/bin/python setup.py install
./venv/bin/pip install -r requirements.txt
sudo ./venv/bin/letsencrypt
Support for other Linux distributions coming soon.

View File

@@ -53,8 +53,7 @@ Installation
.. code-block:: shell
virtualenv --no-site-packages -p python2 venv
./venv/bin/python setup.py install
sudo ./venv/bin/letsencrypt
./venv/bin/pip install -r requirements.txt
Usage

View File

@@ -1,18 +1,31 @@
"""Example Let's Encrypt plugins."""
"""Example Let's Encrypt plugins.
For full examples, see `letsencrypt.client.plugins`.
"""
import zope.interface
from letsencrypt.client import interfaces
from letsencrypt.client.plugins import common
class Authenticator(object):
class Authenticator(common.Plugin):
"""Example Authenticator."""
zope.interface.implements(interfaces.IAuthenticator)
zope.interface.classProvides(interfaces.IPluginFactory)
description = 'Example Authenticator plugin'
def __init__(self, config):
self.config = config
description = "Example Authenticator plugin"
# Implement all methods from IAuthenticator, remembering to add
# "self" as first argument, e.g. def prepare(self)...
# For full examples, see letsencrypt.client.plugins
class Installer(common.Plugins):
"""Example Installer."""
zope.interface.implements(interfaces.IInstaller)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Example Installer plugin"
# Implement all methods from IInstaller, remembering to add
# "self" as first argument, e.g. def get_all_names(self)...

View File

@@ -9,8 +9,9 @@ setup(
'zope.interface',
],
entry_points={
'letsencrypt.authenticators': [
'example = letsencrypt_example_plugins:Authenticator',
'letsencrypt.plugins': [
'example_authenticator = letsencrypt_example_plugins:Authenticator',
'example_installer = letsencrypt_example_plugins:Installer',
],
},
)

View File

@@ -4,9 +4,10 @@ import logging
import augeas
from letsencrypt.client import reverter
from letsencrypt.client.plugins import common
class AugeasConfigurator(object):
class AugeasConfigurator(common.Plugin):
"""Base Augeas Configurator class.
:ivar config: Configuration.
@@ -21,8 +22,8 @@ class AugeasConfigurator(object):
"""
def __init__(self, config):
self.config = config
def __init__(self, *args, **kwargs):
super(AugeasConfigurator, self).__init__(*args, **kwargs)
# Set Augeas flags to not save backup (we do it ourselves)
# Set Augeas to not load anything by default
@@ -34,7 +35,7 @@ class AugeasConfigurator(object):
# This needs to occur before VirtualHost objects are setup...
# because this will change the underlying configuration and potential
# vhosts
self.reverter = reverter.Reverter(config)
self.reverter = reverter.Reverter(self.config)
self.reverter.recovery_routine()
def check_parsing_errors(self, lens):

373
letsencrypt/client/cli.py Normal file
View File

@@ -0,0 +1,373 @@
"""Let's Encrypt CLI."""
# TODO: Sanity check all input. Be sure to avoid shell code etc...
import argparse
import logging
import os
import sys
import configargparse
import zope.component
import zope.interface.exceptions
import zope.interface.verify
import letsencrypt
from letsencrypt.client import account
from letsencrypt.client import configuration
from letsencrypt.client import constants
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
from letsencrypt.client.display import util as display_util
from letsencrypt.client.display import ops as display_ops
from letsencrypt.client.plugins import disco as plugins_disco
def _account_init(args, config):
le_util.make_or_verify_dir(
config.config_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
# Prepare for init of Client
if args.email is None:
return client.determine_account(config)
else:
try:
# The way to get the default would be args.email = ""
# First try existing account
return account.Account.from_existing_account(config, args.email)
except errors.LetsEncryptClientError:
try:
# Try to make an account based on the email address
return account.Account.from_email(config, args.email)
except errors.LetsEncryptClientError:
return None
def _common_run(args, config, acc, authenticator, installer):
if args.domains is None:
doms = display_ops.choose_names(installer)
else:
doms = args.domains
if not doms:
sys.exit("Please specify --domains, or --installer that will "
"help in domain names autodiscovery")
acme = client.Client(config, acc, authenticator, installer)
# Validate the key and csr
client.validate_key_csr(acc.key)
if authenticator is not None:
if acc.regr is None:
try:
acme.register()
except errors.LetsEncryptClientError:
sys.exit("Unable to register an account with ACME server")
return acme, doms
def run(args, config, plugins):
"""Obtain a certificate and install."""
acc = _account_init(args, config)
if acc is None:
return None
if args.configurator is not None and (args.installer is not None or
args.authenticator is not None):
return ("Either --configurator or --authenticator/--installer"
"pair, but not both, is allowed")
if args.authenticator is not None or args.installer is not None:
installer = display_ops.pick_installer(
config, args.installer, plugins)
authenticator = display_ops.pick_authenticator(
config, args.authenticator, plugins)
else:
# TODO: this assume that user doesn't want to pick authenticator
# and installer separately...
authenticator = installer = display_ops.pick_configurator(
config, args.configurator, plugins)
if installer is None or authenticator is None:
return "Configurator could not be determined"
acme, doms = _common_run(args, config, acc, authenticator, installer)
cert_key, cert_path, chain_path = acme.obtain_certificate(doms)
acme.deploy_certificate(doms, cert_key, cert_path, chain_path)
acme.enhance_config(doms, args.redirect)
def auth(args, config, plugins):
"""Obtain a certificate (no install)."""
acc = _account_init(args, config)
if acc is None:
return None
authenticator = display_ops.pick_authenticator(
config, args.authenticator, plugins)
if authenticator is None:
return "Authenticator could not be determined"
if args.installer is not None:
installer = display_ops.pick_installer(config, args.installer, plugins)
else:
installer = None
acme, doms = _common_run(
args, config, acc, authenticator=authenticator, installer=installer)
acme.obtain_certificate(doms)
def install(args, config, plugins):
"""Install (no auth)."""
acc = _account_init(args, config)
if acc is None:
return None
installer = display_ops.pick_installer(config, args.installer, plugins)
if installer is None:
return "Installer could not be determined"
acme, doms = _common_run(
args, config, acc, authenticator=None, installer=installer)
assert args.cert_path is not None
acme.deploy_certificate(doms, acc.key, args.cert_path, args.chain_path)
acme.enhance_config(doms, args.redirect)
def revoke(args, unused_config, unused_plugins):
"""Revoke."""
if args.rev_cert is None and args.rev_key is None:
return "At least one of --certificate or --key is required"
# This depends on the renewal config and cannot be completed yet.
zope.component.getUtility(interfaces.IDisplay).notification(
"Revocation is not available with the new Boulder server yet.")
#client.revoke(args.installer, config, plugins, args.no_confirm,
# args.rev_cert, args.rev_key)
def rollback(args, config, plugins):
"""Rollback."""
client.rollback(args.installer, args.checkpoints, config, plugins)
def config_changes(unused_args, config, unused_plugins):
"""View config changes.
View checkpoints and associated configuration changes.
"""
client.view_config_changes(config)
def plugins_cmd(args, config, plugins): # TODO: Use IDiplay rathern than print
"""List plugins."""
logging.debug("Expected interfaces: %s", args.ifaces)
ifaces = [] if args.ifaces is None else args.ifaces
filtered = plugins.ifaces(ifaces)
logging.debug("Filtered plugins: %r", filtered)
if not args.init and not args.prepare:
print str(filtered)
return
filtered.init(config)
verified = filtered.verify(ifaces)
logging.debug("Verified plugins: %r", verified)
if not args.prepare:
print str(verified)
return
verified.prepare()
available = verified.available()
logging.debug("Prepared plugins: %s", available)
print str(available)
def read_file(filename):
"""Returns the given file's contents with universal new line support.
:param str filename: Filename
:returns: A tuple of filename and its contents
:rtype: tuple
:raises argparse.ArgumentTypeError: File does not exist or is not readable.
"""
try:
return filename, open(filename, "rU").read()
except IOError as exc:
raise argparse.ArgumentTypeError(exc.strerror)
def flag_default(name):
"""Default value for CLI flag."""
return constants.CLI_DEFAULTS[name]
def config_help(name):
"""Help message for `.IConfig` attribute."""
return interfaces.IConfig[name].__doc__
def create_parser(plugins):
"""Create parser."""
parser = configargparse.ArgParser(
description=__doc__,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
args_for_setting_config_path=["-c", "--config"],
default_config_files=flag_default("config_files"))
add = parser.add_argument
# --help is automatically provided by argparse
add("--version", action="version", version="%(prog)s {0}".format(
letsencrypt.__version__))
add("-v", "--verbose", dest="verbose_count", action="count",
default=flag_default("verbose_count"))
add("--no-confirm", dest="no_confirm", action="store_true",
help="Turn off confirmation screens, currently used for --revoke")
add("-e", "--agree-tos", dest="tos", action="store_true",
help="Skip the end user license agreement screen.")
add("-t", "--text", dest="text_mode", action="store_true",
help="Use the text output instead of the curses UI.")
subparsers = parser.add_subparsers(metavar="SUBCOMMAND")
def add_subparser(name, func): # pylint: disable=missing-docstring
subparser = subparsers.add_parser(
name, help=func.__doc__.splitlines()[0], description=func.__doc__)
subparser.set_defaults(func=func)
return subparser
add_subparser("run", run)
add_subparser("auth", auth)
add_subparser("install", install)
parser_revoke = add_subparser("revoke", revoke)
parser_rollback = add_subparser("rollback", rollback)
add_subparser("config_changes", config_changes)
parser_plugins = add_subparser("plugins", plugins_cmd)
parser_plugins.add_argument("--init", action="store_true")
parser_plugins.add_argument("--prepare", action="store_true")
parser_plugins.add_argument(
"--authenticators", action="append_const", dest="ifaces",
const=interfaces.IAuthenticator)
parser_plugins.add_argument(
"--installers", action="append_const", dest="ifaces",
const=interfaces.IInstaller)
parser.add_argument("--configurator")
parser.add_argument("-a", "--authenticator")
parser.add_argument("-i", "--installer")
# positional arg shadows --domains, instead of appending, and
# --domains is useful, because it can be stored in config
#for subparser in parser_run, parser_auth, parser_install:
# subparser.add_argument("domains", nargs="*", metavar="domain")
add("-d", "--domains", metavar="DOMAIN", action="append")
add("-s", "--server", default=flag_default("server"),
help=config_help("server"))
add("-k", "--authkey", type=read_file,
help="Path to the authorized key file")
add("-m", "--email", help=config_help("email"))
add("-B", "--rsa-key-size", type=int, metavar="N",
default=flag_default("rsa_key_size"), help=config_help("rsa_key_size"))
# TODO: resolve - assumes binary logic while client.py assumes ternary.
add("-r", "--redirect", action="store_true",
help="Automatically redirect all HTTP traffic to HTTPS for the newly "
"authenticated vhost.")
parser_revoke.add_argument(
"--certificate", dest="rev_cert", type=read_file, metavar="CERT_PATH",
help="Revoke a specific certificate.")
parser_revoke.add_argument(
"--key", dest="rev_key", type=read_file, metavar="KEY_PATH",
help="Revoke all certs generated by the provided authorized key.")
parser_rollback.add_argument(
"--checkpoints", type=int, metavar="N",
default=flag_default("rollback_checkpoints"),
help="Revert configuration N number of checkpoints.")
_paths_parser(parser.add_argument_group("paths"))
# TODO: plugin_parser should be called for every detected plugin
for name, plugin_ep in plugins.iteritems():
plugin_ep.plugin_cls.inject_parser_options(
parser.add_argument_group(
name, description=plugin_ep.description), name)
return parser
def _paths_parser(parser):
add = parser.add_argument
add("--config-dir", default=flag_default("config_dir"),
help=config_help("config_dir"))
add("--work-dir", default=flag_default("work_dir"),
help=config_help("work_dir"))
add("--backup-dir", default=flag_default("backup_dir"),
help=config_help("backup_dir"))
add("--key-dir", default=flag_default("key_dir"),
help=config_help("key_dir"))
add("--cert-dir", default=flag_default("certs_dir"),
help=config_help("cert_dir"))
add("--le-vhost-ext", default="-le-ssl.conf",
help=config_help("le_vhost_ext"))
add("--cert-path", default=flag_default("cert_path"),
help=config_help("cert_path"))
add("--chain-path", default=flag_default("chain_path"),
help=config_help("chain_path"))
return parser
def main(args=sys.argv[1:]):
"""Command line argument parsing and main script execution."""
# note: arg parser internally handles --help (and exits afterwards)
plugins = plugins_disco.PluginsRegistry.find_all()
args = create_parser(plugins).parse_args(args)
config = configuration.NamespaceConfig(args)
# Displayer
if args.text_mode:
displayer = display_util.FileDisplay(sys.stdout)
else:
displayer = display_util.NcursesDisplay()
zope.component.provideUtility(displayer)
# Logging
level = -args.verbose_count * 10
logger = logging.getLogger()
logger.setLevel(level)
logging.debug("Logging level set at %d", level)
if not args.text_mode:
logger.addHandler(log.DialogHandler())
logging.debug("Discovered plugins: %r", plugins)
if not os.geteuid() == 0:
logging.warning(
"Root (sudo) is required to run most of letsencrypt functionality.")
# check must be done after arg parsing as --help should work
# w/o root; on the other hand, e.g. "letsencrypt run
# --authenticator dns" or "letsencrypt plugins" does not
# require root as well
#return (
# "{0}Root is required to run letsencrypt. Please use sudo.{0}"
# .format(os.linesep))
return args.func(args, config, plugins)
if __name__ == "__main__":
sys.exit(main()) # pragma: no cover

View File

@@ -20,7 +20,6 @@ from letsencrypt.client import network2
from letsencrypt.client import reverter
from letsencrypt.client import revoker
from letsencrypt.client.plugins.apache import configurator
from letsencrypt.client.display import ops as display_ops
from letsencrypt.client.display import enhancements
@@ -154,7 +153,7 @@ class Client(object):
:param str cert_path: Path to attempt to save the cert file
:param str chain_path: Path to attempt to save the chain file
:returns: cert_file, chain_file (absolute paths to the actual files)
:returns: cert_path, chain_path (absolute paths to the actual files)
:rtype: `tuple` of `str`
:raises IOError: If unable to find room to write the cert files
@@ -191,7 +190,7 @@ class Client(object):
return os.path.abspath(act_cert_path), cert_chain_abspath
def deploy_certificate(self, domains, privkey, cert_file, chain_file=None):
def deploy_certificate(self, domains, privkey, cert_path, chain_path=None):
"""Install certificate
:param list domains: list of domains to install the certificate
@@ -199,8 +198,8 @@ class Client(object):
:param privkey: private key for certificate
:type privkey: :class:`letsencrypt.client.le_util.Key`
:param str cert_file: certificate file path
:param str chain_file: chain file path
:param str cert_path: certificate file path
:param str chain_path: chain file path
"""
if self.installer is None:
@@ -208,13 +207,12 @@ class Client(object):
"the certificate")
raise errors.LetsEncryptClientError("No installer available")
chain = None if chain_file is None else os.path.abspath(chain_file)
chain_path = None if chain_path is None else os.path.abspath(chain_path)
for dom in domains:
self.installer.deploy_cert(dom,
os.path.abspath(cert_file),
os.path.abspath(privkey.file),
chain)
self.installer.deploy_cert(
dom, os.path.abspath(cert_path),
os.path.abspath(privkey.file), chain_path)
self.installer.save("Deployed Let's Encrypt Certificate")
# sites may have been enabled / final cleanup
@@ -313,74 +311,6 @@ def validate_key_csr(privkey, csr=None):
"The key and CSR do not match")
def list_available_authenticators(avail_auths):
"""Return a pretty-printed list of authenticators.
This is used to provide helpful feedback in the case where a user
specifies an invalid authenticator on the command line.
"""
output_lines = ["Available authenticators:"]
for auth_name, auth in avail_auths.iteritems():
output_lines.append(" - %s : %s" % (auth_name, auth.description))
return '\n'.join(output_lines)
# This should be controlled by commandline parameters
def determine_authenticator(all_auths, config):
"""Returns a valid IAuthenticator.
:param list all_auths: Where each is a
:class:`letsencrypt.client.interfaces.IAuthenticator` object
:param config: Used if an authenticator was specified on the command line.
:type config: :class:`letsencrypt.client.interfaces.IConfig`
:returns: Valid Authenticator object or None
:raises letsencrypt.client.errors.LetsEncryptClientError: If no
authenticator is available.
"""
# Available Authenticator objects
avail_auths = {}
# Error messages for misconfigured authenticators
errs = {}
for auth_name, auth in all_auths.iteritems():
try:
auth.prepare()
except errors.LetsEncryptMisconfigurationError as err:
errs[auth] = err
except errors.LetsEncryptNoInstallationError:
continue
avail_auths[auth_name] = auth
# If an authenticator was specified on the command line, try to use it
if config.authenticator:
try:
auth = avail_auths[config.authenticator]
except KeyError:
logging.info(list_available_authenticators(avail_auths))
raise errors.LetsEncryptClientError(
"The specified authenticator '%s' could not be found" %
config.authenticator)
elif len(avail_auths) > 1:
auth = display_ops.choose_authenticator(avail_auths.values(), errs)
elif len(avail_auths.keys()) == 1:
auth = avail_auths[avail_auths.keys()[0]]
else:
raise errors.LetsEncryptClientError("No Authenticators available.")
if auth is not None and auth in errs:
logging.error("Please fix the configuration for the Authenticator. "
"The following error message was received: "
"%s", errs[auth])
return
return auth
def determine_account(config):
"""Determine which account to use.
@@ -403,29 +333,7 @@ def determine_account(config):
return account.Account.from_prompts(config)
def determine_installer(config):
"""Returns a valid installer if one exists.
:param config: Configuration.
:type config: :class:`letsencrypt.client.interfaces.IConfig`
:returns: IInstaller or `None`
:rtype: :class:`~letsencrypt.client.interfaces.IInstaller` or `None`
"""
installer = configurator.ApacheConfigurator(config)
try:
installer.prepare()
return installer
except errors.LetsEncryptNoInstallationError:
logging.info("Unable to find a way to install the certificate.")
return
except errors.LetsEncryptMisconfigurationError:
# This will have to be changed in the future...
return installer
def rollback(checkpoints, config):
def rollback(default_installer, checkpoints, config, plugins):
"""Revert configuration the specified number of checkpoints.
:param int checkpoints: Number of checkpoints to revert.
@@ -435,7 +343,9 @@ def rollback(checkpoints, config):
"""
# Misconfigurations are only a slight problems... allow the user to rollback
installer = determine_installer(config)
installer = display_ops.pick_installer(
config, default_installer, plugins, question="Which installer "
"should be used for rollback?")
# No Errors occurred during init... proceed normally
# If installer is None... couldn't find an installer... there shouldn't be
@@ -445,18 +355,16 @@ def rollback(checkpoints, config):
installer.restart()
def revoke(config, no_confirm, cert, authkey):
def revoke(default_installer, config, plugins, no_confirm, cert, authkey):
"""Revoke certificates.
:param config: Configuration.
:type config: :class:`letsencrypt.client.interfaces.IConfig`
"""
# Misconfigurations don't really matter. Determine installer better choose
# correctly though.
# This will need some better prepared or properly configured parameter...
# I will figure it out later...
installer = determine_installer(config)
installer = display_ops.pick_installer(
config, default_installer, plugins, question="Which installer "
"should be used for certificate revocation?")
revoc = revoker.Revoker(installer, config, no_confirm)
# Cert is most selective, so it is chosen first.

View File

@@ -1,9 +1,30 @@
"""Let's Encrypt constants."""
import pkg_resources
import logging
from letsencrypt.acme import challenges
SETUPTOOLS_PLUGINS_ENTRY_POINT = "letsencrypt.plugins"
"""Setuptools entry point group name for plugins."""
CLI_DEFAULTS = dict(
config_files=["/etc/letsencrypt/cli.ini"],
verbose_count=-(logging.WARNING / 10),
server="www.letsencrypt-demo.org/acme/new-reg",
rsa_key_size=2048,
rollback_checkpoints=0,
config_dir="/etc/letsencrypt",
work_dir="/var/lib/letsencrypt",
backup_dir="/var/lib/letsencrypt/backups",
key_dir="/etc/letsencrypt/keys",
certs_dir="/etc/letsencrypt/certs",
cert_path="/etc/letsencrypt/certs/cert-letsencrypt.pem",
chain_path="/etc/letsencrypt/certs/chain-letsencrypt.pem",
)
"""Defaults for CLI flags and `.IConfig` attributes."""
EXCLUSIVE_CHALLENGES = frozenset([frozenset([
challenges.DVSNI, challenges.SimpleHTTPS])])
"""Mutually exclusive challenges."""
@@ -22,22 +43,6 @@ List of expected options parameters:
"""
APACHE_MOD_SSL_CONF = pkg_resources.resource_filename(
"letsencrypt.client.plugins.apache", "options-ssl.conf")
"""Path to the Apache mod_ssl config file found in the Let's Encrypt
distribution."""
APACHE_REWRITE_HTTPS_ARGS = [
"^.*$", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,R=permanent]"]
"""Apache rewrite rule arguments used for redirections to https vhost"""
NGINX_MOD_SSL_CONF = pkg_resources.resource_filename(
"letsencrypt.client.plugins.nginx", "options-ssl.conf")
"""Path to the Nginx mod_ssl config file found in the Let's Encrypt
distribution."""
CONFIG_DIRS_MODE = 0o755
"""Directory mode for ``.IConfig.config_dir`` et al."""

View File

@@ -1,4 +1,5 @@
"""Contains UI methods for LE user operations."""
import logging
import os
import zope.component
@@ -6,41 +7,107 @@ import zope.component
from letsencrypt.client import interfaces
from letsencrypt.client.display import util as display_util
# Define a helper function to avoid verbose code
util = zope.component.getUtility # pylint: disable=invalid-name
def choose_authenticator(auths, errs):
"""Allow the user to choose their authenticator.
def choose_plugin(prepared, question):
"""Allow the user to choose ther plugin.
:param list auths: Where each of type
:class:`letsencrypt.client.interfaces.IAuthenticator` object
:param dict errs: Mapping IAuthenticator objects to error messages
:param list prepared: List of `~.PluginEntryPoint`.
:param str question: Question to be presented to the user.
:returns: Authenticator selected
:rtype: :class:`letsencrypt.client.interfaces.IAuthenticator` or `None`
:returns: Plugin entry point chosen by the user.
:rtype: `~.PluginEntryPoint`
"""
descs = [auth.description if auth not in errs
else "%s (Misconfigured)" % auth.description
for auth in auths]
opts = [plugin_ep.description_with_name
+ (" [Misconfigured]" if plugin_ep.misconfigured else "")
for plugin_ep in prepared]
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")
question, opts, help_label="More Info")
if code == display_util.OK:
return auths[index]
return prepared[index]
elif code == display_util.HELP:
if auths[index] in errs:
msg = "Reported Error: %s" % errs[auths[index]]
if prepared[index].misconfigured:
msg = "Reported Error: %s" % prepared[index].prepare()
else:
msg = auths[index].more_info()
msg = prepared[index].init().more_info()
util(interfaces.IDisplay).notification(
msg, height=display_util.HEIGHT)
else:
return
return None
def pick_plugin(config, default, plugins, question, ifaces):
"""Pick plugin.
:param letsencrypt.client.interfaces.IConfig: Configuration
:param str default: Plugin name supplied by user or ``None``.
:param letsencrypt.client.plugins.disco.PluginsRegistry plugins:
All plugins registered as entry points.
:param str question: Question to be presented to the user in case
multiple candidates are found.
:param list ifaces: Interfaces that plugins must provide.
:returns: Initialized plugin.
:rtype: IPlugin
"""
if default is not None:
# throw more UX-friendly error if default not in plugins
filtered = plugins.filter(lambda p_ep: p_ep.name == default)
else:
filtered = plugins.ifaces(ifaces)
filtered.init(config)
verified = filtered.verify(ifaces)
verified.prepare()
prepared = verified.available()
if len(prepared) > 1:
logging.debug("Multiple candidate plugins: %s", prepared)
plugin_ep = choose_plugin(prepared.values(), question)
if plugin_ep is None:
return None
else:
return plugin_ep.init()
elif len(prepared) == 1:
plugin_ep = prepared.values()[0]
logging.debug("Single candidate plugin: %s", plugin_ep)
return plugin_ep.init()
else:
logging.debug("No candidate plugin")
return None
def pick_authenticator(
config, default, plugins, question="How would you "
"like to authenticate with Let's Encrypt CA?"):
"""Pick authentication plugin."""
return pick_plugin(
config, default, plugins, question, (interfaces.IAuthenticator,))
def pick_installer(config, default, plugins,
question="How would you like to install certificates?"):
"""Pick installer plugin."""
return pick_plugin(
config, default, plugins, question, (interfaces.IInstaller,))
def pick_configurator(
config, default, plugins,
question="How would you like to authenticate and install "
"certificates?"):
"""Pick configurator plugin."""
return pick_plugin(
config, default, plugins, question,
(interfaces.IAuthenticator, interfaces.IInstaller))
def choose_account(accounts):
@@ -76,6 +143,7 @@ def choose_names(installer):
"""
if installer is None:
logging.debug("No installer, picking names manually")
return _choose_names_manually()
names = list(installer.get_all_names())

View File

@@ -5,7 +5,88 @@ import zope.interface
# pylint: disable=too-few-public-methods
class IAuthenticator(zope.interface.Interface):
class IPluginFactory(zope.interface.Interface):
"""IPlugin factory.
Objects providing this interface will be called without satisfying
any entry point "extras" (extra dependencies) you might have defined
for your plugin, e.g (excerpt from ``setup.py`` script)::
setup(
...
entry_points={
'letsencrypt.plugins': [
'name=example_project.plugin[plugin_deps]',
],
},
extras_require={
'plugin_deps': ['dep1', 'dep2'],
}
)
Therefore, make sure such objects are importable and usable without
extras. This is necessary, because CLI does the following operations
(in order):
- loads an entry point,
- calls `inject_parser_options`,
- requires an entry point,
- creates plugin instance (`__call__`).
"""
description = zope.interface.Attribute("Short plugin description")
def __call__(config, name):
"""Create new `IPlugin`.
:param IConfig config: Configuration.
:param str name: Unique plugin name.
"""
def inject_parser_options(parser, name):
"""Inject argument parser options (flags).
1. Be nice and prepend all options and destinations with
`~.option_namespace` and `~.dest_namespace`.
2. Inject options (flags) only. Positional arguments are not
allowed, as this would break the CLI.
:param ArgumentParser parser: (Almost) top-level CLI parser.
:param str name: Unique plugin name.
"""
class IPlugin(zope.interface.Interface):
"""Let's Encrypt plugin."""
def prepare():
"""Prepare the plugin.
Finish up any additional initialization.
:raises letsencrypt.client.errors.LetsEncryptMisconfigurationError:
when full initialization cannot be completed. Plugin will be
displayed on a list of available plugins.
:raises letsencrypt.client.errors.LetsEncryptNoInstallationError:
when the necessary programs/files cannot be located. Plugin
will NOT be displayed on a list of available plugins.
"""
def more_info():
"""Human-readable string to help the user.
Should describe the steps taken and any relevant info to help the user
decide which plugin to use.
"""
class IAuthenticator(IPlugin):
"""Generic Let's Encrypt Authenticator.
Class represents all possible tools processes that have the
@@ -13,22 +94,6 @@ class IAuthenticator(zope.interface.Interface):
"""
description = zope.interface.Attribute(
"Short description of this authenticator. "
"Used in interactive configuration.")
def prepare():
"""Prepare the authenticator.
Finish up any additional initialization.
:raises letsencrypt.client.errors.LetsEncryptMisconfigurationError:
when full initialization cannot be completed.
:raises letsencrypt.client.errors.LetsEncryptNoInstallationError:
when the necessary programs/files cannot be located.
"""
def get_chall_pref(domain):
"""Return list of challenge preferences.
@@ -74,14 +139,6 @@ class IAuthenticator(zope.interface.Interface):
"""
def more_info():
"""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.
@@ -93,8 +150,6 @@ class IConfig(zope.interface.Interface):
server = zope.interface.Attribute(
"CA hostname (and optionally :port). The server certificate must "
"be trusted in order to avoid further modifications to the client.")
authenticator = zope.interface.Attribute(
"Authenticator to use for responding to challenges.")
email = zope.interface.Attribute(
"Email used for registration and recovery contact.")
rsa_key_size = zope.interface.Attribute("Size of the RSA key.")
@@ -120,49 +175,17 @@ class IConfig(zope.interface.Interface):
le_vhost_ext = zope.interface.Attribute(
"SSL vhost configuration extension.")
cert_path = zope.interface.Attribute("Let's Encrypt certificate file.")
chain_path = zope.interface.Attribute("Let's Encrypt chain file.")
apache_server_root = zope.interface.Attribute(
"Apache server root directory.")
apache_ctl = zope.interface.Attribute(
"Path to the 'apache2ctl' binary, used for 'configtest' and "
"retrieving Apache2 version number.")
apache_enmod = zope.interface.Attribute(
"Path to the Apache 'a2enmod' binary.")
apache_init_script = zope.interface.Attribute(
"Path to the Apache init script (used for server reload/restart).")
apache_mod_ssl_conf = zope.interface.Attribute(
"Contains standard Apache SSL directives.")
nginx_server_root = zope.interface.Attribute(
"Nginx server root directory.")
nginx_ctl = zope.interface.Attribute(
"Path to the 'nginx' binary, used for 'configtest' and "
"retrieving nginx version number.")
nginx_mod_ssl_conf = zope.interface.Attribute(
"Contains standard nginx SSL directives.")
cert_path = zope.interface.Attribute("Let's Encrypt certificate file path.")
chain_path = zope.interface.Attribute("Let's Encrypt chain file path.")
class IInstaller(zope.interface.Interface):
class IInstaller(IPlugin):
"""Generic Let's Encrypt Installer Interface.
Represents any server that an X509 certificate can be placed.
"""
def prepare():
"""Prepare the installer.
Finish up any additional initialization.
:raises letsencrypt.client.errors.LetsEncryptMisconfigurationError`:
when full initialization cannot be completed.
:raises letsencrypt.errors.LetsEncryptNoInstallationError`:
when the necessary programs/files cannot be located.
"""
def get_all_names():
"""Returns all names that may be authenticated."""

View File

@@ -13,11 +13,12 @@ from letsencrypt.acme import challenges
from letsencrypt.client import achallenges
from letsencrypt.client import augeas_configurator
from letsencrypt.client import constants
from letsencrypt.client import constants as core_constants
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import le_util
from letsencrypt.client.plugins.apache import constants
from letsencrypt.client.plugins.apache import dvsni
from letsencrypt.client.plugins.apache import obj
from letsencrypt.client.plugins.apache import parser
@@ -79,17 +80,34 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Apache Web Server"
def __init__(self, config, version=None):
@classmethod
def add_parser_arguments(cls, add):
add("server-root", default=constants.CLI_DEFAULTS["server_root"],
help="Apache server root directory.")
add("mod-ssl-conf", default=constants.CLI_DEFAULTS["mod_ssl_conf"],
help="Contains standard Apache SSL directives.")
add("ctl", default=constants.CLI_DEFAULTS["ctl"],
help="Path to the 'apache2ctl' binary, used for 'configtest' and "
"retrieving Apache2 version number.")
add("enmod", default=constants.CLI_DEFAULTS["enmod"],
help="Path to the Apache 'a2enmod' binary.")
add("init-script", default=constants.CLI_DEFAULTS["init_script"],
help="Path to the Apache init script (used for server "
"reload/restart).")
def __init__(self, *args, **kwargs):
"""Initialize an Apache Configurator.
:param tup version: version of Apache as a tuple (2, 4, 7)
(used mostly for unittesting)
"""
super(ApacheConfigurator, self).__init__(config)
version = kwargs.pop('version', None)
super(ApacheConfigurator, self).__init__(*args, **kwargs)
# Verify that all directories and files exist with proper permissions
if os.geteuid() == 0:
@@ -109,8 +127,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
def prepare(self):
"""Prepare the authenticator/installer."""
self.parser = parser.ApacheParser(
self.aug, self.config.apache_server_root,
self.config.apache_mod_ssl_conf)
self.aug, self.conf('server-root'), self.conf('mod-ssl-conf'))
# Check for errors in parsing files with Augeas
self.check_parsing_errors("httpd.aug")
@@ -128,9 +145,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
# on initialization
self._prepare_server_https()
temp_install(self.config.apache_mod_ssl_conf)
temp_install(self.conf('mod-ssl-conf'))
def deploy_cert(self, domain, cert, key, cert_chain=None):
def deploy_cert(self, domain, cert_path, key, chain_path=None):
"""Deploys certificate to specified virtual host.
Currently tries to find the last directives to deploy the cert in
@@ -146,25 +163,26 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
This shouldn't happen within letsencrypt though
:param str domain: domain to deploy certificate
:param str cert: certificate filename
:param str cert_path: certificate filename
:param str key: private key filename
:param str cert_chain: certificate chain filename
:param str chain_path: certificate chain filename
"""
vhost = self.choose_vhost(domain)
# TODO(jdkasten): vhost might be None
path = {}
path["cert_file"] = self.parser.find_dir(parser.case_i(
path["cert_path"] = self.parser.find_dir(parser.case_i(
"SSLCertificateFile"), None, vhost.path)
path["cert_key"] = self.parser.find_dir(parser.case_i(
"SSLCertificateKeyFile"), None, vhost.path)
# Only include if a certificate chain is specified
if cert_chain is not None:
path["cert_chain"] = self.parser.find_dir(
if chain_path is not None:
path["chain_path"] = self.parser.find_dir(
parser.case_i("SSLCertificateChainFile"), None, vhost.path)
if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0:
if not path["cert_path"] or not path["cert_key"]:
# Throw some can't find all of the directives error"
logging.warn(
"Cannot find a cert or key directive in %s", vhost.path)
@@ -174,22 +192,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
logging.info("Deploying Certificate to VirtualHost %s", vhost.filep)
self.aug.set(path["cert_file"][0], cert)
self.aug.set(path["cert_path"][0], cert_path)
self.aug.set(path["cert_key"][0], key)
if cert_chain is not None:
if len(path["cert_chain"]) == 0:
if chain_path is not None:
if not path["chain_path"]:
self.parser.add_dir(
vhost.path, "SSLCertificateChainFile", cert_chain)
vhost.path, "SSLCertificateChainFile", chain_path)
else:
self.aug.set(path["cert_chain"][0], cert_chain)
self.aug.set(path["chain_path"][0], chain_path)
self.save_notes += ("Changed vhost at %s with addresses of %s\n" %
(vhost.filep,
", ".join(str(addr) for addr in vhost.addrs)))
self.save_notes += "\tSSLCertificateFile %s\n" % cert
self.save_notes += "\tSSLCertificateFile %s\n" % cert_path
self.save_notes += "\tSSLCertificateKeyFile %s\n" % key
if cert_chain:
self.save_notes += "\tSSLCertificateChainFile %s\n" % cert_chain
if chain_path is not None:
self.save_notes += "\tSSLCertificateChainFile %s\n" % chain_path
# Make sure vhost is enabled
if not vhost.enabled:
@@ -386,10 +404,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
is appropriately listening on port 443.
"""
if not mod_loaded("ssl_module", self.config.apache_ctl):
if not mod_loaded("ssl_module", self.conf('ctl')):
logging.info("Loading mod_ssl into Apache Server")
enable_mod("ssl", self.config.apache_init_script,
self.config.apache_enmod)
enable_mod("ssl", self.conf('init-script'),
self.conf('enmod'))
# Check for Listen 443
# Note: This could be made to also look for ip:443 combo
@@ -572,9 +590,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
:class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`)
"""
if not mod_loaded("rewrite_module", self.config.apache_ctl):
enable_mod("rewrite", self.config.apache_init_script,
self.config.apache_enmod)
if not mod_loaded("rewrite_module", self.conf('ctl')):
enable_mod("rewrite", self.conf('init-script'), self.conf('enmod'))
general_v = self._general_vhost(ssl_vhost)
if general_v is None:
@@ -599,7 +616,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
# Add directives to server
self.parser.add_dir(general_v.path, "RewriteEngine", "On")
self.parser.add_dir(general_v.path, "RewriteRule",
constants.APACHE_REWRITE_HTTPS_ARGS)
constants.REWRITE_HTTPS_ARGS)
self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" %
(general_v.filep, ssl_vhost.filep))
self.save()
@@ -638,10 +655,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
if not rewrite_path:
# "No existing redirection for virtualhost"
return False, -1
if len(rewrite_path) == len(constants.APACHE_REWRITE_HTTPS_ARGS):
if len(rewrite_path) == len(constants.REWRITE_HTTPS_ARGS):
for idx, match in enumerate(rewrite_path):
if (self.aug.get(match) !=
constants.APACHE_REWRITE_HTTPS_ARGS[idx]):
constants.REWRITE_HTTPS_ARGS[idx]):
# Not a letsencrypt https rewrite
return True, 2
# Existing letsencrypt https rewrite rule is in place
@@ -693,7 +710,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"LogLevel warn\n"
"</VirtualHost>\n"
% (servername, serveralias,
" ".join(constants.APACHE_REWRITE_HTTPS_ARGS)))
" ".join(constants.REWRITE_HTTPS_ARGS)))
# Write out the file
# This is the default name
@@ -897,7 +914,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
:rtype: bool
"""
return apache_restart(self.config.apache_init_script)
return apache_restart(self.conf('init-script'))
def config_test(self): # pylint: disable=no-self-use
"""Check the configuration of Apache for errors.
@@ -908,7 +925,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
try:
proc = subprocess.Popen(
[self.config.apache_ctl, "configtest"],
[self.conf('ctl'), "configtest"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout, stderr = proc.communicate()
@@ -935,11 +952,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
uid = os.geteuid()
le_util.make_or_verify_dir(
self.config.config_dir, constants.CONFIG_DIRS_MODE, uid)
self.config.config_dir, core_constants.CONFIG_DIRS_MODE, uid)
le_util.make_or_verify_dir(
self.config.work_dir, constants.CONFIG_DIRS_MODE, uid)
self.config.work_dir, core_constants.CONFIG_DIRS_MODE, uid)
le_util.make_or_verify_dir(
self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid)
self.config.backup_dir, core_constants.CONFIG_DIRS_MODE, uid)
def get_version(self):
"""Return version of Apache Server.
@@ -955,13 +972,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
try:
proc = subprocess.Popen(
[self.config.apache_ctl, "-v"],
[self.conf('ctl'), "-v"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
text = proc.communicate()[0]
except (OSError, ValueError):
raise errors.LetsEncryptConfiguratorError(
"Unable to run %s -v" % self.config.apache_ctl)
"Unable to run %s -v" % self.conf('ctl'))
regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE)
matches = regex.findall(text)
@@ -1165,4 +1182,4 @@ def temp_install(options_ssl):
# Check to make sure options-ssl.conf is installed
if not os.path.isfile(options_ssl):
shutil.copyfile(constants.APACHE_MOD_SSL_CONF, options_ssl)
shutil.copyfile(constants.MOD_SSL_CONF, options_ssl)

View File

@@ -0,0 +1,22 @@
"""Apache plugin constants."""
import pkg_resources
CLI_DEFAULTS = dict(
server_root="/etc/apache2",
mod_ssl_conf="/etc/letsencrypt/options-ssl.conf",
ctl="apache2ctl",
enmod="a2enmod",
init_script="/etc/init.d/apache2",
)
"""CLI defaults."""
MOD_SSL_CONF = pkg_resources.resource_filename(
"letsencrypt.client.plugins.apache", "options-ssl.conf")
"""Path to the Apache mod_ssl config file found in the Let's Encrypt
distribution."""
REWRITE_HTTPS_ARGS = [
"^.*$", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,R=permanent]"]
"""Apache rewrite rule arguments used for redirections to https vhost"""

View File

@@ -7,8 +7,8 @@ import unittest
import mock
from letsencrypt.client import constants
from letsencrypt.client.plugins.apache import configurator
from letsencrypt.client.plugins.apache import constants
from letsencrypt.client.plugins.apache import obj
@@ -49,7 +49,7 @@ def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"):
def setup_apache_ssl_options(config_dir):
"""Move the ssl_options into position and return the path."""
option_path = os.path.join(config_dir, "options-ssl.conf")
shutil.copyfile(constants.APACHE_MOD_SSL_CONF, option_path)
shutil.copyfile(constants.MOD_SSL_CONF, option_path)
return option_path
@@ -64,7 +64,7 @@ def get_apache_configurator(
# This just states that the ssl module is already loaded
mock_popen().communicate.return_value = ("ssl_module", "")
config = configurator.ApacheConfigurator(
mock.MagicMock(
config=mock.MagicMock(
apache_server_root=config_path,
apache_mod_ssl_conf=ssl_options,
le_vhost_ext="-le-ssl.conf",
@@ -73,7 +73,8 @@ def get_apache_configurator(
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
work_dir=work_dir),
version)
name="apache",
version=version)
config.prepare()

View File

@@ -0,0 +1,71 @@
"""Plugin common functions."""
import zope.interface
from letsencrypt.acme.jose import util as jose_util
from letsencrypt.client import interfaces
def option_namespace(name):
"""ArgumentParser options namespace (prefix of all options)."""
return name + "-"
def dest_namespace(name):
"""ArgumentParser dest namespace (prefix of all destinations)."""
return name + "_"
class Plugin(object):
"""Generic plugin."""
zope.interface.implements(interfaces.IPlugin)
# classProvides is not inherited, subclasses must define it on their own
#zope.interface.classProvides(interfaces.IPluginFactory)
def __init__(self, config, name):
self.config = config
self.name = name
@property
def option_namespace(self):
"""ArgumentParser options namespace (prefix of all options)."""
return option_namespace(self.name)
@property
def dest_namespace(self):
"""ArgumentParser dest namespace (prefix of all destinations)."""
return dest_namespace(self.name)
def dest(self, var):
"""Find a destination for given variable ``var``."""
# this should do exactly the same what ArgumentParser(arg),
# does to "arg" to compute "dest"
return self.dest_namespace + var.replace("-", "_")
def conf(self, var):
"""Find a configuration value for variable ``var``."""
return getattr(self.config, self.dest(var))
@classmethod
def inject_parser_options(cls, parser, name):
"""Inject parser options.
See `~.IPlugin.inject_parser_options` for docs.
"""
# dummy function, doesn't check if dest.startswith(self.dest_namespace)
def add(arg_name_no_prefix, *args, **kwargs):
# pylint: disable=missing-docstring
return parser.add_argument(
"--{0}{1}".format(option_namespace(name), arg_name_no_prefix),
*args, **kwargs)
return cls.add_parser_arguments(add)
@jose_util.abstractclassmethod
def add_parser_arguments(cls, add):
"""Add plugin arguments to the CLI argument parser.
:param callable add: Function that proxies calls to
`argparse.ArgumentParser.add_argument` prepending options
with unique plugin name prefix.
"""

View File

@@ -0,0 +1,61 @@
"""Tests for letsencrypt.client.plugins.common."""
import unittest
import mock
class NamespaceFunctionsTest(unittest.TestCase):
"""Tests for letsencrypt.client.plugins.common.*_namespace functions."""
def test_option_namespace(self):
from letsencrypt.client.plugins.common import option_namespace
self.assertEqual("foo-", option_namespace("foo"))
def test_dest_namespace(self):
from letsencrypt.client.plugins.common import dest_namespace
self.assertEqual("foo_", dest_namespace("foo"))
class PluginTest(unittest.TestCase):
"""Test for letsencrypt.client.plugins.common.Plugin."""
def setUp(self):
from letsencrypt.client.plugins.common import Plugin
class MockPlugin(Plugin): # pylint: disable=missing-docstring
@classmethod
def add_parser_arguments(cls, add):
add("foo-bar", dest="different_to_foo_bar", x=1, y=None)
self.plugin_cls = MockPlugin
self.config = mock.MagicMock()
self.plugin = MockPlugin(config=self.config, name="mock")
def test_init(self):
self.assertEqual("mock", self.plugin.name)
self.assertEqual(self.config, self.plugin.config)
def test_option_namespace(self):
self.assertEqual("mock-", self.plugin.option_namespace)
def test_dest_namespace(self):
self.assertEqual("mock_", self.plugin.dest_namespace)
def test_dest(self):
self.assertEqual("mock_foo_bar", self.plugin.dest("foo-bar"))
self.assertEqual("mock_foo_bar", self.plugin.dest("foo_bar"))
def test_conf(self):
self.assertEqual(self.config.mock_foo_bar, self.plugin.conf("foo-bar"))
def test_inject_parser_options(self):
parser = mock.MagicMock()
self.plugin_cls.inject_parser_options(parser, "mock")
# note that inject_parser_options doesn't check if dest has
# correct prefix
parser.add_argument.assert_called_once_with(
"--mock-foo-bar", dest="different_to_foo_bar", x=1, y=None)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,224 @@
"""Utilities for plugins discovery and selection."""
import collections
import logging
import pkg_resources
import zope.interface
from letsencrypt.client import constants
from letsencrypt.client import errors
from letsencrypt.client import interfaces
class PluginEntryPoint(object):
"""Plugin entry point."""
PREFIX_FREE_DISTRIBUTIONS = ["letsencrypt"]
"""Distributions for which prefix will be omitted."""
# this object is mutable, don't allow it to be hashed!
__hash__ = None
def __init__(self, entry_point):
self.name = self.entry_point_to_plugin_name(entry_point)
self.plugin_cls = entry_point.load()
self.entry_point = entry_point
self._initialized = None
self._prepared = None
@classmethod
def entry_point_to_plugin_name(cls, entry_point):
"""Unique plugin name for an ``entry_point``"""
if entry_point.dist.key in cls.PREFIX_FREE_DISTRIBUTIONS:
return entry_point.name
return entry_point.dist.key + ":" + entry_point.name
@property
def description(self):
"""Description of the plugin."""
return self.plugin_cls.description
@property
def description_with_name(self):
"""Description with name. Handy for UI."""
return "{0} ({1})".format(self.description, self.name)
def ifaces(self, *ifaces_groups):
"""Does plugin implements specified interface groups?"""
return not ifaces_groups or any(
all(iface.implementedBy(self.plugin_cls)
for iface in ifaces)
for ifaces in ifaces_groups)
@property
def initialized(self):
"""Has the plugin been initialized already?"""
return self._initialized is not None
def init(self, config=None):
"""Memoized plugin inititialization."""
if not self.initialized:
self.entry_point.require() # fetch extras!
self._initialized = self.plugin_cls(config, self.name)
return self._initialized
def verify(self, ifaces):
"""Verify that the plugin conforms to the specified interfaces."""
assert self.initialized
for iface in ifaces: # zope.interface.providedBy(plugin)
try:
zope.interface.verify.verifyObject(iface, self.init())
except zope.interface.exceptions.BrokenImplementation:
if iface.implementedBy(self.plugin_cls):
logging.debug(
"%s implements %s but object does "
"not verify", self.plugin_cls, iface.__name__)
return False
return True
@property
def prepared(self):
"""Has the plugin been prepared already?"""
if not self.initialized:
logging.debug(".prepared called on uninitialized %r", self)
return self._prepared is not None
def prepare(self):
"""Memoized plugin preparation."""
assert self.initialized
if self._prepared is None:
try:
self._initialized.prepare()
except errors.LetsEncryptMisconfigurationError as error:
logging.debug("Misconfigured %r: %s", self, error)
self._prepared = error
except errors.LetsEncryptNoInstallationError as error:
logging.debug("No installation (%r): %s", self, error)
self._prepared = error
else:
self._prepared = True
return self._prepared
@property
def misconfigured(self):
"""Is plugin misconfigured?"""
return isinstance(
self._prepared, errors.LetsEncryptMisconfigurationError)
@property
def available(self):
"""Is plugin available, i.e. prepared or misconfigured?"""
return self._prepared is True or self.misconfigured
def __repr__(self):
return "PluginEntryPoint#{0}".format(self.name)
def __str__(self):
lines = [
"* {0}".format(self.name),
"Description: {0}".format(self.plugin_cls.description),
"Interfaces: {0}".format(", ".join(
iface.__name__ for iface in zope.interface.implementedBy(
self.plugin_cls))),
"Entry point: {0}".format(self.entry_point),
]
if self.initialized:
lines.append("Initialized: {0}".format(self.init()))
if self.prepared:
lines.append("Prep: {0}".format(self.prepare()))
return "\n".join(lines)
class PluginsRegistry(collections.Mapping):
"""Plugins registry."""
def __init__(self, plugins):
self._plugins = plugins
@classmethod
def find_all(cls):
"""Find plugins using setuptools entry points."""
plugins = {}
for entry_point in pkg_resources.iter_entry_points(
constants.SETUPTOOLS_PLUGINS_ENTRY_POINT):
plugin_ep = PluginEntryPoint(entry_point)
assert plugin_ep.name not in plugins, (
"PREFIX_FREE_DISTRIBUTIONS messed up")
# providedBy | pylint: disable=no-member
if interfaces.IPluginFactory.providedBy(plugin_ep.plugin_cls):
plugins[plugin_ep.name] = plugin_ep
else: # pragma: no cover
logging.warning(
"%r does not provide IPluginFactory, skipping", plugin_ep)
return cls(plugins)
def __getitem__(self, name):
return self._plugins[name]
def __iter__(self):
return iter(self._plugins)
def __len__(self):
return len(self._plugins)
def init(self, config):
"""Initialize all plugins in the registry."""
return [plugin_ep.init(config) for plugin_ep
in self._plugins.itervalues()]
def filter(self, pred):
"""Filter plugins based on predicate."""
return type(self)(dict((name, plugin_ep) for name, plugin_ep
in self._plugins.iteritems() if pred(plugin_ep)))
def ifaces(self, *ifaces_groups):
"""Filter plugins based on interfaces."""
# pylint: disable=star-args
return self.filter(lambda p_ep: p_ep.ifaces(*ifaces_groups))
def verify(self, ifaces):
"""Filter plugins based on verification."""
return self.filter(lambda p_ep: p_ep.verify(ifaces))
def prepare(self):
"""Prepare all plugins in the registry."""
return [plugin_ep.prepare() for plugin_ep in self._plugins.itervalues()]
def available(self):
"""Filter plugins based on availability."""
return self.filter(lambda p_ep: p_ep.available)
# succefully prepared + misconfigured
def find_init(self, plugin):
"""Find an initialized plugin.
This is particularly useful for finding a name for the plugin
(although `.IPluginFactory.__call__` takes ``name`` as one of
the arguments, ``IPlugin.name`` is not part of the interface)::
# plugin is an instance providing IPlugin, initialized
# somewhere else in the code
plugin_registry.find_init(plugin).name
Returns ``None`` if ``plugin`` is not found in the registry.
"""
# use list instead of set beacse PluginEntryPoint is not hashable
candidates = [plugin_ep for plugin_ep in self._plugins.itervalues()
if plugin_ep.initialized and plugin_ep.init() is plugin]
assert len(candidates) <= 1
if candidates:
return candidates[0]
else:
return None
def __repr__(self):
return "{0}({1!r})".format(
self.__class__.__name__, set(self._plugins.itervalues()))
def __str__(self):
if not self._plugins:
return "No plugins"
return "\n\n".join(str(p_ep) for p_ep in self._plugins.itervalues())

View File

@@ -0,0 +1,243 @@
"""Tests for letsencrypt.client.plugins.disco."""
import pkg_resources
import unittest
import mock
import zope.interface
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client.plugins.standalone import authenticator
EP_SA = pkg_resources.EntryPoint(
"sa", "letsencrypt.client.plugins.standalone.authenticator",
attrs=("StandaloneAuthenticator",),
dist=mock.MagicMock(key="letsencrypt"))
class PluginEntryPointTest(unittest.TestCase):
"""Tests for letsencrypt.client.plugins.disco.PluginEntryPoint."""
def setUp(self):
self.ep1 = pkg_resources.EntryPoint(
"ep1", "p1.ep1", dist=mock.MagicMock(key="p1"))
self.ep1prim = pkg_resources.EntryPoint(
"ep1", "p2.ep2", dist=mock.MagicMock(key="p2"))
# nested
self.ep2 = pkg_resources.EntryPoint(
"ep2", "p2.foo.ep2", dist=mock.MagicMock(key="p2"))
# project name != top-level package name
self.ep3 = pkg_resources.EntryPoint(
"ep3", "a.ep3", dist=mock.MagicMock(key="p3"))
from letsencrypt.client.plugins.disco import PluginEntryPoint
self.plugin_ep = PluginEntryPoint(EP_SA)
def test_entry_point_to_plugin_name(self):
from letsencrypt.client.plugins.disco import PluginEntryPoint
names = {
self.ep1: "p1:ep1",
self.ep1prim: "p2:ep1",
self.ep2: "p2:ep2",
self.ep3: "p3:ep3",
EP_SA: "sa",
}
for entry_point, name in names.iteritems():
self.assertEqual(
name, PluginEntryPoint.entry_point_to_plugin_name(entry_point))
def test_description(self):
self.assertEqual("Standalone Authenticator", self.plugin_ep.description)
def test_description_with_name(self):
self.plugin_ep.plugin_cls = mock.MagicMock(description="Desc")
self.assertEqual(
"Desc (sa)", self.plugin_ep.description_with_name)
def test_ifaces(self):
self.assertTrue(self.plugin_ep.ifaces((interfaces.IAuthenticator,)))
self.assertFalse(self.plugin_ep.ifaces((interfaces.IInstaller,)))
self.assertFalse(self.plugin_ep.ifaces((
interfaces.IInstaller, interfaces.IAuthenticator)))
def test__init__(self):
self.assertFalse(self.plugin_ep.initialized)
self.assertFalse(self.plugin_ep.prepared)
self.assertFalse(self.plugin_ep.misconfigured)
self.assertFalse(self.plugin_ep.available)
self.assertTrue(self.plugin_ep.entry_point is EP_SA)
self.assertEqual("sa", self.plugin_ep.name)
self.assertTrue(
self.plugin_ep.plugin_cls is authenticator.StandaloneAuthenticator)
def test_init(self):
config = mock.MagicMock()
plugin = self.plugin_ep.init(config=config)
self.assertTrue(self.plugin_ep.initialized)
self.assertTrue(plugin.config is config)
# memoize!
self.assertTrue(self.plugin_ep.init() is plugin)
self.assertTrue(plugin.config is config)
# try to give different config
self.assertTrue(self.plugin_ep.init(123) is plugin)
self.assertTrue(plugin.config is config)
self.assertFalse(self.plugin_ep.prepared)
self.assertFalse(self.plugin_ep.misconfigured)
self.assertFalse(self.plugin_ep.available)
def test_verify(self):
iface1 = mock.MagicMock(__name__="iface1")
iface2 = mock.MagicMock(__name__="iface2")
iface3 = mock.MagicMock(__name__="iface3")
# pylint: disable=protected-access
self.plugin_ep._initialized = plugin = mock.MagicMock()
exceptions = zope.interface.exceptions
with mock.patch("letsencrypt.client.plugins."
"disco.zope.interface") as mock_zope:
mock_zope.exceptions = exceptions
def verify_object(iface, obj): # pylint: disable=missing-docstring
assert obj is plugin
assert iface is iface1 or iface is iface2 or iface is iface3
if iface is iface3:
raise mock_zope.exceptions.BrokenImplementation(None, None)
mock_zope.verify.verifyObject.side_effect = verify_object
self.assertTrue(self.plugin_ep.verify((iface1,)))
self.assertTrue(self.plugin_ep.verify((iface1, iface2)))
self.assertFalse(self.plugin_ep.verify((iface3,)))
self.assertFalse(self.plugin_ep.verify((iface1, iface3)))
def test_prepare(self):
config = mock.MagicMock()
self.plugin_ep.init(config=config)
self.plugin_ep.prepare()
self.assertTrue(self.plugin_ep.prepared)
self.assertFalse(self.plugin_ep.misconfigured)
# output doesn't matter that much, just test if it runs
str(self.plugin_ep)
def test_prepare_misconfigured(self):
plugin = mock.MagicMock()
plugin.prepare.side_effect = errors.LetsEncryptMisconfigurationError
# pylint: disable=protected-access
self.plugin_ep._initialized = plugin
self.assertTrue(isinstance(self.plugin_ep.prepare(),
errors.LetsEncryptMisconfigurationError))
self.assertTrue(self.plugin_ep.prepared)
self.assertTrue(self.plugin_ep.misconfigured)
self.assertTrue(self.plugin_ep.available)
def test_prepare_no_installation(self):
plugin = mock.MagicMock()
plugin.prepare.side_effect = errors.LetsEncryptNoInstallationError
# pylint: disable=protected-access
self.plugin_ep._initialized = plugin
self.assertTrue(isinstance(self.plugin_ep.prepare(),
errors.LetsEncryptNoInstallationError))
self.assertTrue(self.plugin_ep.prepared)
self.assertFalse(self.plugin_ep.misconfigured)
self.assertFalse(self.plugin_ep.available)
def test_repr(self):
self.assertEqual("PluginEntryPoint#sa", repr(self.plugin_ep))
class PluginsRegistryTest(unittest.TestCase):
"""Tests for letsencrypt.client.plugins.disco.PluginsRegistry."""
def setUp(self):
from letsencrypt.client.plugins.disco import PluginsRegistry
self.plugin_ep = mock.MagicMock(name="mock")
self.plugins = {"mock": self.plugin_ep}
self.reg = PluginsRegistry(self.plugins)
def test_find_all(self):
from letsencrypt.client.plugins.disco import PluginsRegistry
with mock.patch("letsencrypt.client.plugins.disco"
".pkg_resources") as mock_pkg:
mock_pkg.iter_entry_points.return_value = iter([EP_SA])
plugins = PluginsRegistry.find_all()
self.assertTrue(plugins["sa"].plugin_cls
is authenticator.StandaloneAuthenticator)
self.assertTrue(plugins["sa"].entry_point is EP_SA)
def test_getitem(self):
self.assertEqual(self.plugin_ep, self.reg["mock"])
def test_iter(self):
self.assertEqual(["mock"], list(self.reg))
def test_len(self):
self.assertEqual(1, len(self.reg))
self.plugins.clear()
self.assertEqual(0, len(self.reg))
def test_init(self):
self.plugin_ep.init.return_value = "baz"
self.assertEqual(["baz"], self.reg.init("bar"))
self.plugin_ep.init.assert_called_once_with("bar")
def test_filter(self):
self.plugins.update({
"foo": "bar",
"bar": "foo",
"baz": "boo",
})
self.assertEqual(
{"foo": "bar", "baz": "boo"},
self.reg.filter(lambda p_ep: str(p_ep).startswith("b")))
def test_ifaces(self):
self.plugin_ep.ifaces.return_value = True
# pylint: disable=protected-access
self.assertEqual(self.plugins, self.reg.ifaces()._plugins)
self.plugin_ep.ifaces.return_value = False
self.assertEqual({}, self.reg.ifaces()._plugins)
def test_verify(self):
self.plugin_ep.verify.return_value = True
# pylint: disable=protected-access
self.assertEqual(
self.plugins, self.reg.verify(mock.MagicMock())._plugins)
self.plugin_ep.verify.return_value = False
self.assertEqual({}, self.reg.verify(mock.MagicMock())._plugins)
def test_prepare(self):
self.plugin_ep.prepare.return_value = "baz"
self.assertEqual(["baz"], self.reg.prepare())
self.plugin_ep.prepare.assert_called_once_with()
def test_available(self):
self.plugin_ep.available = True
# pylint: disable=protected-access
self.assertEqual(self.plugins, self.reg.available()._plugins)
self.plugin_ep.available = False
self.assertEqual({}, self.reg.available()._plugins)
def test_find_init(self):
self.assertTrue(self.reg.find_init(mock.Mock()) is None)
self.plugin_ep.initalized = True
self.assertTrue(
self.reg.find_init(self.plugin_ep.init()) is self.plugin_ep)
def test_repr(self):
self.plugin_ep.__repr__ = lambda _: "PluginEntryPoint#mock"
self.assertEqual("PluginsRegistry(set([PluginEntryPoint#mock]))",
repr(self.reg))
def test_str(self):
self.plugin_ep.__str__ = lambda _: "Mock"
self.plugins["foo"] = "Mock"
self.assertEqual("Mock\n\nMock", str(self.reg))
self.plugins.clear()
self.assertEqual("No plugins", str(self.reg))
if __name__ == "__main__":
unittest.main()

View File

@@ -12,17 +12,20 @@ import zope.interface
from letsencrypt.acme import challenges
from letsencrypt.client import achallenges
from letsencrypt.client import constants
from letsencrypt.client import constants as core_constants
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import le_util
from letsencrypt.client import reverter
from letsencrypt.client.plugins import common
from letsencrypt.client.plugins.nginx import constants
from letsencrypt.client.plugins.nginx import dvsni
from letsencrypt.client.plugins.nginx import parser
class NginxConfigurator(object):
class NginxConfigurator(common.Plugin):
# pylint: disable=too-many-instance-attributes,too-many-public-methods
"""Nginx configurator.
@@ -46,17 +49,29 @@ class NginxConfigurator(object):
"""
zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Nginx Web Server"
def __init__(self, config, version=None):
@classmethod
def add_parser_arguments(cls, add):
add("server-root", default=constants.CLI_DEFAULTS["server_root"],
help="Nginx server root directory.")
add("mod-ssl-conf", default=constants.CLI_DEFAULTS["mod_ssl_conf"],
help="Contains standard nginx SSL directives.")
add("ctl", default=constants.CLI_DEFAULTS["ctl"], help="Path to the "
"'nginx' binary, used for 'configtest' and retrieving nginx "
"version number.")
def __init__(self, *args, **kwargs):
"""Initialize an Nginx Configurator.
:param tup version: version of Nginx as a tuple (1, 4, 7)
(used mostly for unittesting)
"""
self.config = config
version = kwargs.pop("version", None)
super(NginxConfigurator, self).__init__(*args, **kwargs)
# Verify that all directories and files exist with proper permissions
if os.geteuid() == 0:
@@ -74,21 +89,21 @@ class NginxConfigurator(object):
self._enhance_func = {} # TODO: Support at least redirects
# Set up reverter
self.reverter = reverter.Reverter(config)
self.reverter = reverter.Reverter(self.config)
self.reverter.recovery_routine()
# This is called in determine_authenticator and determine_installer
def prepare(self):
"""Prepare the authenticator/installer."""
self.parser = parser.NginxParser(
self.config.nginx_server_root,
self.config.nginx_mod_ssl_conf)
self.conf('server-root'),
self.conf('mod-ssl-conf'))
# Set Version
if self.version is None:
self.version = self.get_version()
temp_install(self.config.nginx_mod_ssl_conf)
temp_install(self.conf('mod-ssl-conf'))
# Entry point in main.py for installing cert
def deploy_cert(self, domain, cert, key, cert_chain=None):
@@ -312,7 +327,7 @@ class NginxConfigurator(object):
:rtype: bool
"""
return nginx_restart(self.config.nginx_ctl)
return nginx_restart(self.conf('ctl'))
def config_test(self): # pylint: disable=no-self-use
"""Check the configuration of Nginx for errors.
@@ -323,7 +338,7 @@ class NginxConfigurator(object):
"""
try:
proc = subprocess.Popen(
[self.config.nginx_ctl, "-t"],
[self.conf('ctl'), "-t"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout, stderr = proc.communicate()
@@ -350,11 +365,11 @@ class NginxConfigurator(object):
"""
uid = os.geteuid()
le_util.make_or_verify_dir(
self.config.work_dir, constants.CONFIG_DIRS_MODE, uid)
self.config.work_dir, core_constants.CONFIG_DIRS_MODE, uid)
le_util.make_or_verify_dir(
self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid)
self.config.backup_dir, core_constants.CONFIG_DIRS_MODE, uid)
le_util.make_or_verify_dir(
self.config.config_dir, constants.CONFIG_DIRS_MODE, uid)
self.config.config_dir, core_constants.CONFIG_DIRS_MODE, uid)
def get_version(self):
"""Return version of Nginx Server.
@@ -370,13 +385,13 @@ class NginxConfigurator(object):
"""
try:
proc = subprocess.Popen(
[self.config.nginx_ctl, "-V"],
[self.conf('ctl'), "-V"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
text = proc.communicate()[1] # nginx prints output to stderr
except (OSError, ValueError):
raise errors.LetsEncryptConfiguratorError(
"Unable to run %s -V" % self.config.nginx_ctl)
"Unable to run %s -V" % self.conf('ctl'))
version_regex = re.compile(r"nginx/([0-9\.]*)", re.IGNORECASE)
version_matches = version_regex.findall(text)
@@ -561,4 +576,4 @@ def temp_install(options_ssl):
# Check to make sure options-ssl.conf is installed
if not os.path.isfile(options_ssl):
shutil.copyfile(constants.NGINX_MOD_SSL_CONF, options_ssl)
shutil.copyfile(constants.MOD_SSL_CONF, options_ssl)

View File

@@ -0,0 +1,16 @@
"""nginx plugin constants."""
import pkg_resources
CLI_DEFAULTS = dict(
server_root="/etc/nginx",
mod_ssl_conf="/etc/letsencrypt/options-ssl-nginx.conf",
ctl="nginx",
)
"""CLI defaults."""
MOD_SSL_CONF = pkg_resources.resource_filename(
"letsencrypt.client.plugins.nginx", "options-ssl.conf")
"""Path to the Nginx mod_ssl config file found in the Let's Encrypt
distribution."""

View File

@@ -7,7 +7,7 @@ import unittest
import mock
from letsencrypt.client import constants
from letsencrypt.client.plugins.nginx import constants
from letsencrypt.client.plugins.nginx import configurator
@@ -54,7 +54,7 @@ def dir_setup(test_dir="debian_nginx/two_vhost_80"):
def setup_nginx_ssl_options(config_dir):
"""Move the ssl_options into position and return the path."""
option_path = os.path.join(config_dir, "options-ssl.conf")
shutil.copyfile(constants.NGINX_MOD_SSL_CONF, option_path)
shutil.copyfile(constants.MOD_SSL_CONF, option_path)
return option_path
@@ -65,12 +65,13 @@ def get_nginx_configurator(
backups = os.path.join(work_dir, "backups")
config = configurator.NginxConfigurator(
mock.MagicMock(
config=mock.MagicMock(
nginx_server_root=config_path, nginx_mod_ssl_conf=ssl_options,
le_vhost_ext="-le-ssl.conf", backup_dir=backups,
config_dir=config_dir, work_dir=work_dir,
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
in_progress_dir=os.path.join(backups, "IN_PROGRESS")),
version)
name="nginx",
version=version)
config.prepare()
return config

View File

@@ -17,8 +17,10 @@ from letsencrypt.acme import challenges
from letsencrypt.client import achallenges
from letsencrypt.client import interfaces
from letsencrypt.client.plugins import common
class StandaloneAuthenticator(object):
class StandaloneAuthenticator(common.Plugin):
# pylint: disable=too-many-instance-attributes
"""Standalone authenticator.
@@ -29,10 +31,12 @@ class StandaloneAuthenticator(object):
"""
zope.interface.implements(interfaces.IAuthenticator)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Standalone Authenticator"
def __init__(self, unused_config):
def __init__(self, *args, **kwargs):
super(StandaloneAuthenticator, self).__init__(*args, **kwargs)
self.child_pid = None
self.parent_pid = os.getpid()
self.subproc_state = None

View File

@@ -59,7 +59,7 @@ class ChallPrefTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
def test_chall_pref(self):
self.assertEqual(self.authenticator.get_chall_pref("example.com"),
@@ -71,7 +71,7 @@ class SNICallbackTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
self.cert = achallenges.DVSNI(
challb=acme_util.DVSNI_P,
domain="example.com", key=KEY).gen_cert_and_response()[0]
@@ -109,7 +109,7 @@ class ClientSignalHandlerTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
self.authenticator.tasks = {"foononce.acme.invalid": "stuff"}
self.authenticator.child_pid = 12345
@@ -138,7 +138,7 @@ class SubprocSignalHandlerTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
self.authenticator.tasks = {"foononce.acme.invalid": "stuff"}
self.authenticator.child_pid = 12345
self.authenticator.parent_pid = 23456
@@ -190,7 +190,7 @@ class AlreadyListeningTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
@mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil."
"net_connections")
@@ -297,7 +297,7 @@ class PerformTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
self.achall1 = achallenges.DVSNI(
challb=acme_util.chall_to_challb(
@@ -371,7 +371,7 @@ class StartListenerTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
@mock.patch("letsencrypt.client.plugins.standalone.authenticator."
"Crypto.Random.atfork")
@@ -406,7 +406,7 @@ class DoParentProcessTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
@mock.patch("letsencrypt.client.plugins.standalone.authenticator."
"signal.signal")
@@ -460,7 +460,7 @@ class DoChildProcessTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
self.cert = achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(r=("x" * 32), nonce="abcdef"), "pending"),
@@ -549,7 +549,7 @@ class CleanupTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
self.achall = achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="foononce"), "pending"),
@@ -582,7 +582,7 @@ class MoreInfoTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import (
StandaloneAuthenticator)
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
def test_more_info(self):
"""Make sure exceptions aren't raised."""
@@ -594,7 +594,7 @@ class InitTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.standalone.authenticator import (
StandaloneAuthenticator)
self.authenticator = StandaloneAuthenticator(None)
self.authenticator = StandaloneAuthenticator(config=None, name=None)
def test_prepare(self):
"""Make sure exceptions aren't raised.

View File

@@ -0,0 +1,35 @@
"""Tests for letsencrypt.client.cli."""
import itertools
import unittest
import mock
class CLITest(unittest.TestCase):
"""Tests for different commands."""
@classmethod
def _call(cls, args):
from letsencrypt.client import cli
args = ['--text'] + args
with mock.patch("letsencrypt.client.cli.sys.stdout") as stdout:
with mock.patch("letsencrypt.client.cli.sys.stderr") as stderr:
ret = cli.main(args)
return ret, stdout, stderr
def test_no_flags(self):
self.assertRaises(SystemExit, self._call, [])
def test_help(self):
self.assertRaises(SystemExit, self._call, ['--help'])
def test_plugins(self):
flags = ['--init', '--prepare', '--authenticators', '--installers']
for args in itertools.chain(
*(itertools.combinations(flags, r)
for r in xrange(len(flags)))):
self._call(['plugins',] + list(args))
if __name__ == '__main__':
unittest.main()

View File

@@ -8,7 +8,6 @@ import mock
from letsencrypt.client import account
from letsencrypt.client import configuration
from letsencrypt.client import errors
from letsencrypt.client import le_util
@@ -54,92 +53,6 @@ class DetermineAccountTest(unittest.TestCase):
self.assertTrue(chosen_acc.email, test_acc.email)
class DetermineAuthenticatorTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.plugins.apache.configurator import (
ApacheConfigurator)
from letsencrypt.client.plugins.standalone.authenticator import (
StandaloneAuthenticator)
self.mock_stand = mock.MagicMock(
spec=StandaloneAuthenticator, description="Apache Web Server")
self.mock_apache = mock.MagicMock(
spec=ApacheConfigurator, description="Standalone Authenticator")
self.mock_config = mock.MagicMock(
spec=configuration.NamespaceConfig, authenticator=None)
self.all_auths = {
'apache': self.mock_apache,
'standalone': self.mock_stand
}
@classmethod
def _call(cls, all_auths, config):
from letsencrypt.client.client import determine_authenticator
return determine_authenticator(all_auths, config)
@mock.patch("letsencrypt.client.client.display_ops.choose_authenticator")
def test_accept_two(self, mock_choose):
mock_choose.return_value = self.mock_stand()
self.assertEqual(self._call(self.all_auths, self.mock_config),
self.mock_stand())
def test_accept_one(self):
self.mock_apache.prepare.return_value = self.mock_apache
one_avail_auth = {
'apache': self.mock_apache
}
self.assertEqual(self._call(one_avail_auth, self.mock_config),
self.mock_apache)
def test_no_installation_one(self):
self.mock_apache.prepare.side_effect = (
errors.LetsEncryptNoInstallationError)
self.assertEqual(self._call(self.all_auths, self.mock_config),
self.mock_stand)
def test_no_installations(self):
self.mock_apache.prepare.side_effect = (
errors.LetsEncryptNoInstallationError)
self.mock_stand.prepare.side_effect = (
errors.LetsEncryptNoInstallationError)
self.assertRaises(errors.LetsEncryptClientError,
self._call,
self.all_auths,
self.mock_config)
@mock.patch("letsencrypt.client.client.logging")
@mock.patch("letsencrypt.client.client.display_ops.choose_authenticator")
def test_misconfigured(self, mock_choose, unused_log):
self.mock_apache.prepare.side_effect = (
errors.LetsEncryptMisconfigurationError)
mock_choose.return_value = self.mock_apache
self.assertTrue(self._call(self.all_auths, self.mock_config) is None)
def test_choose_valid_auth_from_cmd_line(self):
standalone_config = mock.MagicMock(spec=configuration.NamespaceConfig,
authenticator='standalone')
self.assertEqual(self._call(self.all_auths, standalone_config),
self.mock_stand)
apache_config = mock.MagicMock(spec=configuration.NamespaceConfig,
authenticator='apache')
self.assertEqual(self._call(self.all_auths, apache_config),
self.mock_apache)
def test_choose_invalid_auth_from_cmd_line(self):
invalid_config = mock.MagicMock(spec=configuration.NamespaceConfig,
authenticator='foobar')
self.assertRaises(errors.LetsEncryptClientError,
self._call,
self.all_auths,
invalid_config)
class RollbackTest(unittest.TestCase):
"""Test the rollback function."""
def setUp(self):
@@ -150,23 +63,22 @@ class RollbackTest(unittest.TestCase):
@classmethod
def _call(cls, checkpoints):
from letsencrypt.client.client import rollback
rollback(checkpoints, mock.MagicMock())
rollback(None, checkpoints, {}, mock.MagicMock())
@mock.patch("letsencrypt.client.client.determine_installer")
def test_no_problems(self, mock_det):
mock_det.side_effect = self.m_install
@mock.patch("letsencrypt.client.client.display_ops.pick_installer")
def test_no_problems(self, mock_pick_installer):
mock_pick_installer.side_effect = self.m_install
self._call(1)
self.assertEqual(self.m_install().rollback_checkpoints.call_count, 1)
self.assertEqual(self.m_install().restart.call_count, 1)
@mock.patch("letsencrypt.client.client.determine_installer")
def test_no_installer(self, mock_det):
mock_det.return_value = None
# Just make sure no exceptions are raised
@mock.patch("letsencrypt.client.client.display_ops.pick_installer")
def test_no_installer(self, mock_pick_installer):
mock_pick_installer.return_value = None
self._call(1)
# Just make sure no exceptions are raised
if __name__ == "__main__":

View File

@@ -8,34 +8,35 @@ import mock
import zope.component
from letsencrypt.client import account
from letsencrypt.client import interfaces
from letsencrypt.client import le_util
from letsencrypt.client.display import util as display_util
class ChooseAuthenticatorTest(unittest.TestCase):
"""Test choose_authenticator function."""
class ChoosePluginTest(unittest.TestCase):
"""Tests for letsencrypt.client.display.ops.choose_plugin."""
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.mock_apache = mock.Mock(
description_with_name="a", misconfigured=True)
self.mock_stand = mock.Mock(
description_with_name="s", misconfigured=False)
self.mock_stand.init().more_info.return_value = "standalone"
self.plugins = [
self.mock_apache,
self.mock_stand,
]
self.auths = [self.mock_apache, self.mock_stand]
self.errs = {self.mock_apache: "This is an error message."}
@classmethod
def _call(cls, auths, errs):
from letsencrypt.client.display.ops import choose_authenticator
return choose_authenticator(auths, errs)
def _call(self):
from letsencrypt.client.display.ops import choose_plugin
return choose_plugin(self.plugins, "Question?")
@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(self.auths, {})
self.assertEqual(ret, self.mock_apache)
self.assertEqual(self.mock_apache, self._call())
@mock.patch("letsencrypt.client.display.ops.util")
def test_more_info(self, mock_util):
@@ -45,15 +46,100 @@ class ChooseAuthenticatorTest(unittest.TestCase):
(display_util.OK, 1),
]
ret = self._call(self.auths, self.errs)
self.assertEqual(self.mock_stand, self._call())
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.assertTrue(self._call(self.auths, {}) is None)
self.assertTrue(self._call() is None)
class PickPluginTest(unittest.TestCase):
"""Tests for letsencrypt.client.display.ops.pick_plugin."""
def setUp(self):
self.config = mock.Mock()
self.default = None
self.reg = mock.MagicMock()
self.question = "Question?"
self.ifaces = []
def _call(self):
from letsencrypt.client.display.ops import pick_plugin
return pick_plugin(self.config, self.default, self.reg,
self.question, self.ifaces)
def test_default_provided(self):
self.default = "foo"
self._call()
self.reg.filter.assert_called_once()
def test_no_default(self):
self._call()
self.reg.filter.assert_called_once()
def test_no_candidate(self):
self.assertTrue(self._call() is None)
def test_single(self):
plugin_ep = mock.MagicMock()
plugin_ep.init.return_value = "foo"
self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep}
self.assertEqual("foo", self._call())
def test_multiple(self):
plugin_ep = mock.MagicMock()
plugin_ep.init.return_value = "foo"
self.reg.ifaces().verify().available.return_value = {
"bar": plugin_ep,
"baz": plugin_ep,
}
with mock.patch("letsencrypt.client.display"
".ops.choose_plugin") as mock_choose:
mock_choose.return_value = plugin_ep
self.assertEqual("foo", self._call())
mock_choose.assert_called_once_with(
[plugin_ep, plugin_ep], self.question)
def test_choose_plugin_none(self):
self.reg.ifaces().verify().available.return_value = {
"bar": None,
"baz": None,
}
with mock.patch("letsencrypt.client.display"
".ops.choose_plugin") as mock_choose:
mock_choose.return_value = None
self.assertTrue(self._call() is None)
class ConveniencePickPluginTest(unittest.TestCase):
"""Tests for letsencrypt.client.display.ops.pick_*."""
def _test(self, fun, ifaces):
config = mock.Mock()
default = mock.Mock()
plugins = mock.Mock()
with mock.patch("letsencrypt.client.display.ops.pick_plugin") as mock_p:
mock_p.return_value = "foo"
self.assertEqual("foo", fun(config, default, plugins, "Question?"))
mock_p.assert_called_once_with(
config, default, plugins, "Question?", ifaces)
def test_authenticator(self):
from letsencrypt.client.display.ops import pick_authenticator
self._test(pick_authenticator, (interfaces.IAuthenticator,))
def test_installer(self):
from letsencrypt.client.display.ops import pick_installer
self._test(pick_installer, (interfaces.IInstaller,))
def test_configurator(self):
from letsencrypt.client.display.ops import pick_configurator
self._test(pick_configurator, (
interfaces.IAuthenticator, interfaces.IInstaller))
class ChooseAccountTest(unittest.TestCase):

View File

@@ -1 +0,0 @@
"""Let's Encrypt scripts."""

View File

@@ -1,272 +0,0 @@
"""Parse command line and call the appropriate functions.
.. todo:: Sanity check all input. Be sure to avoid shell code etc...
"""
import argparse
import logging
import os
import pkg_resources
import sys
import confargparse
import zope.component
import zope.interface.exceptions
import zope.interface.verify
import letsencrypt
from letsencrypt.client import account
from letsencrypt.client import configuration
from letsencrypt.client import constants
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
from letsencrypt.client.display import util as display_util
from letsencrypt.client.display import ops as display_ops
SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = "letsencrypt.authenticators"
"""Setuptools entry point group name for Authenticator plugins."""
def init_auths(config):
"""Find (setuptools entry points) and initialize Authenticators."""
# TODO: handle collisions in authenticator names. Or is this
# already handled for us by pkg_resources?
auths = {}
for entrypoint in pkg_resources.iter_entry_points(
SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT):
auth_cls = entrypoint.load()
auth = auth_cls(config)
try:
zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth)
except zope.interface.exceptions.BrokenImplementation:
logging.debug(
"%r object does not provide IAuthenticator, skipping",
entrypoint.name)
else:
auths[entrypoint.name] = auth
return auths
def create_parser():
"""Create parser."""
parser = confargparse.ConfArgParser(
description="letsencrypt client %s" % letsencrypt.__version__)
add = parser.add_argument
config_help = lambda name: interfaces.IConfig[name].__doc__
add("-d", "--domains", metavar="DOMAIN", nargs="+")
add("-s", "--server",
default="www.letsencrypt-demo.org/acme/new-reg",
help=config_help("server"))
# TODO: we should generate the list of choices from the set of
# available authenticators, but that is tricky due to the
# dependency between init_auths and config. Hardcoding it for now.
add("-a", "--authenticator", dest="authenticator",
help=config_help("authenticator"))
add("-k", "--authkey", type=read_file,
help="Path to the authorized key file")
add("-m", "--email", type=str,
help="Email address used for account registration.")
add("-B", "--rsa-key-size", type=int, default=2048, metavar="N",
help=config_help("rsa_key_size"))
add("-R", "--revoke", action="store_true",
help="Revoke a certificate from a menu.")
add("--revoke-certificate", dest="rev_cert", type=read_file,
help="Revoke a specific certificate.")
add("--revoke-key", dest="rev_key", type=read_file,
help="Revoke all certs generated by the provided authorized key.")
add("-b", "--rollback", type=int, default=0, metavar="N",
help="Revert configuration N number of checkpoints.")
add("-v", "--view-config-changes", action="store_true",
help="View checkpoints and associated configuration changes.")
# TODO: resolve - assumes binary logic while client.py assumes ternary.
add("-r", "--redirect", action="store_true",
help="Automatically redirect all HTTP traffic to HTTPS for the newly "
"authenticated vhost.")
add("--no-confirm", dest="no_confirm", action="store_true",
help="Turn off confirmation screens, currently used for --revoke")
add("-e", "--agree-tos", dest="tos", action="store_true",
help="Skip the end user license agreement screen.")
add("-t", "--text", dest="use_curses", action="store_false",
help="Use the text output instead of the curses UI.")
add("--config-dir", default="/etc/letsencrypt",
help=config_help("config_dir"))
add("--work-dir", default="/var/lib/letsencrypt",
help=config_help("work_dir"))
add("--backup-dir", default="/var/lib/letsencrypt/backups",
help=config_help("backup_dir"))
add("--key-dir", default="/etc/letsencrypt/keys",
help=config_help("key_dir"))
add("--cert-dir", default="/etc/letsencrypt/certs",
help=config_help("cert_dir"))
add("--le-vhost-ext", default="-le-ssl.conf",
help=config_help("le_vhost_ext"))
add("--cert-path", default="/etc/letsencrypt/certs/cert-letsencrypt.pem",
help=config_help("cert_path"))
add("--chain-path", default="/etc/letsencrypt/certs/chain-letsencrypt.pem",
help=config_help("chain_path"))
add("--apache-server-root", default="/etc/apache2",
help=config_help("apache_server_root"))
add("--apache-mod-ssl-conf", default="/etc/letsencrypt/options-ssl.conf",
help=config_help("apache_mod_ssl_conf"))
add("--apache-ctl", default="apache2ctl", help=config_help("apache_ctl"))
add("--apache-enmod", default="a2enmod", help=config_help("apache_enmod"))
add("--apache-init-script", default="/etc/init.d/apache2",
help=config_help("apache_init_script"))
add("--nginx-server-root", default="/etc/nginx",
help=config_help("nginx_server_root"))
add("--nginx-mod-ssl-conf",
default="/etc/letsencrypt/options-ssl-nginx.conf",
help=config_help("nginx_mod_ssl_conf"))
add("--nginx-ctl", default="nginx", help=config_help("nginx_ctl"))
return parser
def main(): # pylint: disable=too-many-branches, too-many-statements
"""Command line argument parsing and main script execution."""
# note: arg parser internally handles --help (and exits afterwards)
args = create_parser().parse_args()
config = configuration.NamespaceConfig(args)
# note: check is done after arg parsing as --help should work w/o root also.
if not os.geteuid() == 0:
sys.exit(
"{0}Root is required to run letsencrypt. Please use sudo.{0}"
.format(os.linesep))
# Set up logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
if args.use_curses:
logger.addHandler(log.DialogHandler())
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()
if args.revoke or args.rev_cert is not None or args.rev_key is not None:
# This depends on the renewal config and cannot be completed yet.
zope.component.getUtility(interfaces.IDisplay).notification(
"Revocation is not available with the new Boulder server yet.")
# client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key)
sys.exit()
if args.rollback > 0:
client.rollback(args.rollback, config)
sys.exit()
le_util.make_or_verify_dir(
config.config_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
# Prepare for init of Client
if args.email is None:
acc = client.determine_account(config)
else:
try:
# The way to get the default would be args.email = ""
# First try existing account
acc = account.Account.from_existing_account(config, args.email)
except errors.LetsEncryptClientError:
try:
# Try to make an account based on the email address
acc = account.Account.from_email(config, args.email)
except errors.LetsEncryptClientError:
sys.exit(1)
if acc is None:
sys.exit(0)
all_auths = init_auths(config)
logging.debug('Initialized authenticators: %s', all_auths.keys())
try:
auth = client.determine_authenticator(all_auths, config)
logging.debug("Selected authenticator: %s", auth)
except errors.LetsEncryptClientError as err:
logging.critical(str(err))
sys.exit(1)
if auth is None:
sys.exit(0)
# Use the same object if possible
if interfaces.IInstaller.providedBy(auth): # pylint: disable=no-member
installer = auth
else:
# This is simple and avoids confusion right now.
installer = None
if args.domains is None:
doms = display_ops.choose_names(installer)
else:
doms = args.domains
if not doms:
sys.exit(0)
acme = client.Client(config, acc, auth, installer)
# Validate the key and csr
client.validate_key_csr(acc.key)
# This more closely mimics the capabilities of the CLI
# It should be possible for reconfig only, install-only, no-install
# I am not sure the best way to handle all of the unimplemented abilities,
# but this code should be safe on all environments.
cert_file = None
if auth is not None:
if acc.regr is None:
try:
acme.register()
except errors.LetsEncryptClientError:
sys.exit(0)
cert_key, cert_file, chain_file = acme.obtain_certificate(doms)
if installer is not None and cert_file is not None:
acme.deploy_certificate(doms, cert_key, cert_file, chain_file)
if installer is not None:
acme.enhance_config(doms, args.redirect)
def read_file(filename):
"""Returns the given file's contents with universal new line support.
:param str filename: Filename
:returns: A tuple of filename and its contents
:rtype: tuple
:raises argparse.ArgumentTypeError: File does not exist or is not readable.
"""
try:
return filename, open(filename, "rU").read()
except IOError as exc:
raise argparse.ArgumentTypeError(exc.strerror)
if __name__ == "__main__":
main()

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://github.com/bw2/ConfigArgParse/issues/17
-e git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse
-e .

View File

@@ -29,7 +29,7 @@ changes = read_file(os.path.join(here, 'CHANGES.rst'))
install_requires = [
'argparse',
'ConfArgParse',
'ConfigArgParse',
'configobj',
'jsonschema',
'mock',
@@ -112,7 +112,6 @@ setup(
'letsencrypt.client.plugins.standalone.tests',
'letsencrypt.client.tests',
'letsencrypt.client.tests.display',
'letsencrypt.scripts',
],
install_requires=install_requires,
@@ -127,10 +126,10 @@ setup(
entry_points={
'console_scripts': [
'letsencrypt = letsencrypt.scripts.main:main',
'letsencrypt = letsencrypt.client.cli:main',
'jws = letsencrypt.acme.jose.jws:CLI.run',
],
'letsencrypt.authenticators': [
'letsencrypt.plugins': [
'apache = letsencrypt.client.plugins.apache.configurator'
':ApacheConfigurator',
'nginx = letsencrypt.client.plugins.nginx.configurator'

View File

@@ -7,7 +7,7 @@ envlist = py26,py27,cover,lint
[testenv]
commands =
pip install -e .[testing]
pip install -r requirements.txt -e .[testing]
python setup.py test -q # -q does not suppress errors
setenv =