mirror of
https://github.com/certbot/certbot.git
synced 2026-01-21 19:01:07 +03:00
1993 lines
85 KiB
Python
1993 lines
85 KiB
Python
"""Let's Encrypt CLI."""
|
|
from __future__ import print_function
|
|
|
|
# TODO: Sanity check all input. Be sure to avoid shell code etc...
|
|
# pylint: disable=too-many-lines
|
|
# (TODO: split this file into main.py and cli.py)
|
|
import argparse
|
|
import atexit
|
|
import copy
|
|
import functools
|
|
import glob
|
|
import json
|
|
import logging
|
|
import logging.handlers
|
|
import os
|
|
import sys
|
|
import time
|
|
import traceback
|
|
|
|
import configargparse
|
|
import OpenSSL
|
|
import zope.component
|
|
import zope.interface.exceptions
|
|
import zope.interface.verify
|
|
|
|
from acme import jose
|
|
|
|
import letsencrypt
|
|
|
|
from letsencrypt import account
|
|
from letsencrypt import colored_logging
|
|
from letsencrypt import configuration
|
|
from letsencrypt import constants
|
|
from letsencrypt import client
|
|
from letsencrypt import crypto_util
|
|
from letsencrypt import errors
|
|
from letsencrypt import interfaces
|
|
from letsencrypt import le_util
|
|
from letsencrypt import log
|
|
from letsencrypt import reporter
|
|
from letsencrypt import storage
|
|
|
|
from letsencrypt.display import util as display_util
|
|
from letsencrypt.display import ops as display_ops
|
|
from letsencrypt.plugins import disco as plugins_disco
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Global, to save us from a lot of argument passing within the scope of this module
|
|
_parser = None
|
|
|
|
# These are the items which get pulled out of a renewal configuration
|
|
# file's renewalparams and actually used in the client configuration
|
|
# during the renewal process. We have to record their types here because
|
|
# the renewal configuration process loses this information.
|
|
STR_CONFIG_ITEMS = ["config_dir", "logs_dir", "work_dir", "user_agent",
|
|
"server", "account", "authenticator", "installer",
|
|
"standalone_supported_challenges"]
|
|
INT_CONFIG_ITEMS = ["rsa_key_size", "tls_sni_01_port", "http01_port"]
|
|
|
|
# For help strings, figure out how the user ran us.
|
|
# When invoked from letsencrypt-auto, sys.argv[0] is something like:
|
|
# "/home/user/.local/share/letsencrypt/bin/letsencrypt"
|
|
# Note that this won't work if the user set VENV_PATH or XDG_DATA_HOME before running
|
|
# letsencrypt-auto (and sudo stops us from seeing if they did), so it should only be used
|
|
# for purposes where inability to detect letsencrypt-auto fails safely
|
|
|
|
fragment = os.path.join(".local", "share", "letsencrypt")
|
|
cli_command = "letsencrypt-auto" if fragment in sys.argv[0] else "letsencrypt"
|
|
|
|
# Argparse's help formatting has a lot of unhelpful peculiarities, so we want
|
|
# to replace as much of it as we can...
|
|
|
|
# This is the stub to include in help generated by argparse
|
|
|
|
SHORT_USAGE = """
|
|
{0} [SUBCOMMAND] [options] [-d domain] [-d domain] ...
|
|
|
|
The Let's Encrypt agent can obtain and install HTTPS/TLS/SSL certificates. By
|
|
default, it will attempt to use a webserver both for obtaining and installing
|
|
the cert. Major SUBCOMMANDS are:
|
|
|
|
(default) run Obtain & install a cert in your current webserver
|
|
certonly Obtain cert, but do not install it (aka "auth")
|
|
install Install a previously obtained cert in a server
|
|
renew Renew previously obtained certs that are near expiry
|
|
revoke Revoke a previously obtained certificate
|
|
rollback Rollback server configuration changes made during install
|
|
config_changes Show changes made to server config during installation
|
|
plugins Display information about installed plugins
|
|
|
|
""".format(cli_command)
|
|
|
|
# This is the short help for letsencrypt --help, where we disable argparse
|
|
# altogether
|
|
USAGE = SHORT_USAGE + """Choice of server plugins for obtaining and installing cert:
|
|
|
|
%s
|
|
--standalone Run a standalone webserver for authentication
|
|
%s
|
|
--webroot Place files in a server's webroot folder for authentication
|
|
|
|
OR use different plugins to obtain (authenticate) the cert and then install it:
|
|
|
|
--authenticator standalone --installer apache
|
|
|
|
More detailed help:
|
|
|
|
-h, --help [topic] print this message, or detailed help on a topic;
|
|
the available topics are:
|
|
|
|
all, automation, paths, security, testing, or any of the subcommands or
|
|
plugins (certonly, install, nginx, apache, standalone, webroot, etc)
|
|
"""
|
|
|
|
|
|
def usage_strings(plugins):
|
|
"""Make usage strings late so that plugins can be initialised late"""
|
|
if "nginx" in plugins:
|
|
nginx_doc = "--nginx Use the Nginx plugin for authentication & installation"
|
|
else:
|
|
nginx_doc = "(nginx support is experimental, buggy, and not installed by default)"
|
|
if "apache" in plugins:
|
|
apache_doc = "--apache Use the Apache plugin for authentication & installation"
|
|
else:
|
|
apache_doc = "(the apache plugin is not installed)"
|
|
return USAGE % (apache_doc, nginx_doc), SHORT_USAGE
|
|
|
|
|
|
def _find_domains(config, installer):
|
|
if not config.domains:
|
|
domains = display_ops.choose_names(installer)
|
|
# record in config.domains (so that it can be serialised in renewal config files),
|
|
# and set webroot_map entries if applicable
|
|
for d in domains:
|
|
_process_domain(config, d)
|
|
else:
|
|
domains = config.domains
|
|
|
|
if not domains:
|
|
raise errors.Error("Please specify --domains, or --installer that "
|
|
"will help in domain names autodiscovery")
|
|
|
|
return domains
|
|
|
|
|
|
def _determine_account(config):
|
|
"""Determine which account to use.
|
|
|
|
In order to make the renewer (configuration de/serialization) happy,
|
|
if ``config.account`` is ``None``, it will be updated based on the
|
|
user input. Same for ``config.email``.
|
|
|
|
:param argparse.Namespace config: CLI arguments
|
|
:param letsencrypt.interface.IConfig config: Configuration object
|
|
:param .AccountStorage account_storage: Account storage.
|
|
|
|
:returns: Account and optionally ACME client API (biproduct of new
|
|
registration).
|
|
:rtype: `tuple` of `letsencrypt.account.Account` and
|
|
`acme.client.Client`
|
|
|
|
"""
|
|
account_storage = account.AccountFileStorage(config)
|
|
acme = None
|
|
|
|
if config.account is not None:
|
|
acc = account_storage.load(config.account)
|
|
else:
|
|
accounts = account_storage.find_all()
|
|
if len(accounts) > 1:
|
|
acc = display_ops.choose_account(accounts)
|
|
elif len(accounts) == 1:
|
|
acc = accounts[0]
|
|
else: # no account registered yet
|
|
if config.email is None and not config.register_unsafely_without_email:
|
|
config.namespace.email = display_ops.get_email()
|
|
|
|
def _tos_cb(regr):
|
|
if config.tos:
|
|
return True
|
|
msg = ("Please read the Terms of Service at {0}. You "
|
|
"must agree in order to register with the ACME "
|
|
"server at {1}".format(
|
|
regr.terms_of_service, config.server))
|
|
obj = zope.component.getUtility(interfaces.IDisplay)
|
|
return obj.yesno(msg, "Agree", "Cancel", cli_flag="--agree-tos")
|
|
|
|
try:
|
|
acc, acme = client.register(
|
|
config, account_storage, tos_cb=_tos_cb)
|
|
except errors.MissingCommandlineFlag:
|
|
raise
|
|
except errors.Error as error:
|
|
logger.debug(error, exc_info=True)
|
|
raise errors.Error(
|
|
"Unable to register an account with ACME server")
|
|
|
|
config.namespace.account = acc.id
|
|
return acc, acme
|
|
|
|
|
|
def _init_le_client(config, authenticator, installer):
|
|
if authenticator is not None:
|
|
# if authenticator was given, then we will need account...
|
|
acc, acme = _determine_account(config)
|
|
logger.debug("Picked account: %r", acc)
|
|
# XXX
|
|
#crypto_util.validate_key_csr(acc.key)
|
|
else:
|
|
acc, acme = None, None
|
|
|
|
return client.Client(config, acc, authenticator, installer, acme=acme)
|
|
|
|
|
|
def _find_duplicative_certs(config, domains):
|
|
"""Find existing certs that duplicate the request."""
|
|
|
|
identical_names_cert, subset_names_cert = None, None
|
|
|
|
cli_config = configuration.RenewerConfiguration(config)
|
|
configs_dir = cli_config.renewal_configs_dir
|
|
# Verify the directory is there
|
|
le_util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid())
|
|
|
|
for renewal_file in _renewal_conf_files(cli_config):
|
|
try:
|
|
candidate_lineage = storage.RenewableCert(renewal_file, cli_config)
|
|
except (errors.CertStorageError, IOError):
|
|
logger.warning("Renewal conf file %s is broken. Skipping.", renewal_file)
|
|
logger.debug("Traceback was:\n%s", traceback.format_exc())
|
|
continue
|
|
# TODO: Handle these differently depending on whether they are
|
|
# expired or still valid?
|
|
candidate_names = set(candidate_lineage.names())
|
|
if candidate_names == set(domains):
|
|
identical_names_cert = candidate_lineage
|
|
elif candidate_names.issubset(set(domains)):
|
|
# This logic finds and returns the largest subset-names cert
|
|
# in the case where there are several available.
|
|
if subset_names_cert is None:
|
|
subset_names_cert = candidate_lineage
|
|
elif len(candidate_names) > len(subset_names_cert.names()):
|
|
subset_names_cert = candidate_lineage
|
|
|
|
return identical_names_cert, subset_names_cert
|
|
|
|
|
|
def _treat_as_renewal(config, domains):
|
|
"""Determine whether there are duplicated names and how to handle
|
|
them (renew, reinstall, newcert, or raising an error to stop
|
|
the client run if the user chooses to cancel the operation when
|
|
prompted).
|
|
|
|
:returns: Two-element tuple containing desired new-certificate behavior as
|
|
a string token ("reinstall", "renew", or "newcert"), plus either
|
|
a RenewableCert instance or None if renewal shouldn't occur.
|
|
|
|
:raises .Error: If the user would like to rerun the client again.
|
|
|
|
"""
|
|
# Considering the possibility that the requested certificate is
|
|
# related to an existing certificate. (config.duplicate, which
|
|
# is set with --duplicate, skips all of this logic and forces any
|
|
# kind of certificate to be obtained with renewal = False.)
|
|
if config.duplicate:
|
|
return "newcert", None
|
|
# TODO: Also address superset case
|
|
ident_names_cert, subset_names_cert = _find_duplicative_certs(config, domains)
|
|
# XXX ^ schoen is not sure whether that correctly reads the systemwide
|
|
# configuration file.
|
|
if ident_names_cert is None and subset_names_cert is None:
|
|
return "newcert", None
|
|
|
|
if ident_names_cert is not None:
|
|
return _handle_identical_cert_request(config, ident_names_cert)
|
|
elif subset_names_cert is not None:
|
|
return _handle_subset_cert_request(config, domains, subset_names_cert)
|
|
|
|
|
|
def _should_renew(config, lineage):
|
|
"Return true if any of the circumstances for automatic renewal apply."
|
|
if config.renew_by_default:
|
|
logger.info("Auto-renewal forced with --force-renewal...")
|
|
return True
|
|
if lineage.should_autorenew(interactive=True):
|
|
logger.info("Cert is due for renewal, auto-renewing...")
|
|
return True
|
|
if config.dry_run:
|
|
logger.info("Cert not due for renewal, but simulating renewal for dry run")
|
|
return True
|
|
logger.info("Cert not yet due for renewal")
|
|
return False
|
|
|
|
|
|
def _handle_identical_cert_request(config, cert):
|
|
"""Figure out what to do if a cert has the same names as a previously obtained one
|
|
|
|
:param storage.RenewableCert cert:
|
|
|
|
:returns: Tuple of (string, cert_or_None) as per _treat_as_renewal
|
|
:rtype: tuple
|
|
|
|
"""
|
|
if _should_renew(config, cert):
|
|
return "renew", cert
|
|
if config.reinstall:
|
|
# Set with --reinstall, force an identical certificate to be
|
|
# reinstalled without further prompting.
|
|
return "reinstall", cert
|
|
question = (
|
|
"You have an existing certificate that contains exactly the same "
|
|
"domains you requested and isn't close to expiry."
|
|
"{br}(ref: {0}){br}{br}What would you like to do?"
|
|
).format(cert.configfile.filename, br=os.linesep)
|
|
|
|
if config.verb == "run":
|
|
keep_opt = "Attempt to reinstall this existing certificate"
|
|
elif config.verb == "certonly":
|
|
keep_opt = "Keep the existing certificate for now"
|
|
choices = [keep_opt,
|
|
"Renew & replace the cert (limit ~5 per 7 days)"]
|
|
|
|
display = zope.component.getUtility(interfaces.IDisplay)
|
|
response = display.menu(question, choices, "OK", "Cancel", default=0)
|
|
if response[0] == display_util.CANCEL:
|
|
# TODO: Add notification related to command-line options for
|
|
# skipping the menu for this case.
|
|
raise errors.Error(
|
|
"User chose to cancel the operation and may "
|
|
"reinvoke the client.")
|
|
elif response[1] == 0:
|
|
return "reinstall", cert
|
|
elif response[1] == 1:
|
|
return "renew", cert
|
|
else:
|
|
assert False, "This is impossible"
|
|
|
|
|
|
def _handle_subset_cert_request(config, domains, cert):
|
|
"""Figure out what to do if a previous cert had a subset of the names now requested
|
|
|
|
:param storage.RenewableCert cert:
|
|
|
|
:returns: Tuple of (string, cert_or_None) as per _treat_as_renewal
|
|
:rtype: tuple
|
|
|
|
"""
|
|
existing = ", ".join(cert.names())
|
|
question = (
|
|
"You have an existing certificate that contains a portion of "
|
|
"the domains you requested (ref: {0}){br}{br}It contains these "
|
|
"names: {1}{br}{br}You requested these names for the new "
|
|
"certificate: {2}.{br}{br}Do you want to expand and replace this existing "
|
|
"certificate with the new certificate?"
|
|
).format(cert.configfile.filename,
|
|
existing,
|
|
", ".join(domains),
|
|
br=os.linesep)
|
|
if config.expand or config.renew_by_default or zope.component.getUtility(
|
|
interfaces.IDisplay).yesno(question, "Expand", "Cancel",
|
|
cli_flag="--expand (or in some cases, --duplicate)"):
|
|
return "renew", cert
|
|
else:
|
|
reporter_util = zope.component.getUtility(interfaces.IReporter)
|
|
reporter_util.add_message(
|
|
"To obtain a new certificate that contains these names without "
|
|
"replacing your existing certificate for {0}, you must use the "
|
|
"--duplicate option.{br}{br}"
|
|
"For example:{br}{br}{1} --duplicate {2}".format(
|
|
existing,
|
|
sys.argv[0], " ".join(sys.argv[1:]),
|
|
br=os.linesep
|
|
),
|
|
reporter_util.HIGH_PRIORITY)
|
|
raise errors.Error(
|
|
"User chose to cancel the operation and may "
|
|
"reinvoke the client.")
|
|
|
|
|
|
def _report_new_cert(cert_path, fullchain_path):
|
|
"""Reports the creation of a new certificate to the user.
|
|
|
|
:param str cert_path: path to cert
|
|
:param str fullchain_path: path to full chain
|
|
|
|
"""
|
|
expiry = crypto_util.notAfter(cert_path).date()
|
|
reporter_util = zope.component.getUtility(interfaces.IReporter)
|
|
if fullchain_path:
|
|
# Print the path to fullchain.pem because that's what modern webservers
|
|
# (Nginx and Apache2.4) will want.
|
|
and_chain = "and chain have"
|
|
path = fullchain_path
|
|
else:
|
|
# Unless we're in .csr mode and there really isn't one
|
|
and_chain = "has "
|
|
path = cert_path
|
|
# XXX Perhaps one day we could detect the presence of known old webservers
|
|
# and say something more informative here.
|
|
msg = ("Congratulations! Your certificate {0} been saved at {1}."
|
|
" Your cert will expire on {2}. To obtain a new version of the "
|
|
"certificate in the future, simply run Let's Encrypt again."
|
|
.format(and_chain, path, expiry))
|
|
reporter_util.add_message(msg, reporter_util.MEDIUM_PRIORITY)
|
|
|
|
|
|
def _suggest_donation_if_appropriate(config, action):
|
|
"""Potentially suggest a donation to support Let's Encrypt."""
|
|
if config.staging or config.verb == "renew":
|
|
# --dry-run implies --staging
|
|
return
|
|
if action not in ["renew", "newcert"]:
|
|
return
|
|
reporter_util = zope.component.getUtility(interfaces.IReporter)
|
|
msg = ("If you like Let's Encrypt, please consider supporting our work by:\n\n"
|
|
"Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate\n"
|
|
"Donating to EFF: https://eff.org/donate-le\n\n")
|
|
reporter_util.add_message(msg, reporter_util.LOW_PRIORITY)
|
|
|
|
|
|
def _report_successful_dry_run(config):
|
|
reporter_util = zope.component.getUtility(interfaces.IReporter)
|
|
if config.verb != "renew":
|
|
reporter_util.add_message("The dry run was successful.",
|
|
reporter_util.HIGH_PRIORITY, on_crash=False)
|
|
|
|
|
|
def _auth_from_domains(le_client, config, domains, lineage=None):
|
|
"""Authenticate and enroll certificate."""
|
|
# Note: This can raise errors... caught above us though. This is now
|
|
# a three-way case: reinstall (which results in a no-op here because
|
|
# although there is a relevant lineage, we don't do anything to it
|
|
# inside this function -- we don't obtain a new certificate), renew
|
|
# (which results in treating the request as a renewal), or newcert
|
|
# (which results in treating the request as a new certificate request).
|
|
|
|
# If lineage is specified, use that one instead of looking around for
|
|
# a matching one.
|
|
if lineage is None:
|
|
# This will find a relevant matching lineage that exists
|
|
action, lineage = _treat_as_renewal(config, domains)
|
|
else:
|
|
# Renewal, where we already know the specific lineage we're
|
|
# interested in
|
|
action = "renew"
|
|
|
|
if action == "reinstall":
|
|
# The lineage already exists; allow the caller to try installing
|
|
# it without getting a new certificate at all.
|
|
return lineage, "reinstall"
|
|
elif action == "renew":
|
|
original_server = lineage.configuration["renewalparams"]["server"]
|
|
_avoid_invalidating_lineage(config, lineage, original_server)
|
|
# TODO: schoen wishes to reuse key - discussion
|
|
# https://github.com/letsencrypt/letsencrypt/pull/777/files#r40498574
|
|
new_certr, new_chain, new_key, _ = le_client.obtain_certificate(domains)
|
|
# TODO: Check whether it worked! <- or make sure errors are thrown (jdk)
|
|
if config.dry_run:
|
|
logger.info("Dry run: skipping updating lineage at %s",
|
|
os.path.dirname(lineage.cert))
|
|
else:
|
|
lineage.save_successor(
|
|
lineage.latest_common_version(), OpenSSL.crypto.dump_certificate(
|
|
OpenSSL.crypto.FILETYPE_PEM, new_certr.body.wrapped),
|
|
new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain),
|
|
configuration.RenewerConfiguration(config.namespace))
|
|
lineage.update_all_links_to(lineage.latest_common_version())
|
|
# TODO: Check return value of save_successor
|
|
# TODO: Also update lineage renewal config with any relevant
|
|
# configuration values from this attempt? <- Absolutely (jdkasten)
|
|
elif action == "newcert":
|
|
# TREAT AS NEW REQUEST
|
|
lineage = le_client.obtain_and_enroll_certificate(domains)
|
|
if lineage is False:
|
|
raise errors.Error("Certificate could not be obtained")
|
|
|
|
if not config.dry_run and not config.verb == "renew":
|
|
_report_new_cert(lineage.cert, lineage.fullchain)
|
|
|
|
return lineage, action
|
|
|
|
|
|
def _avoid_invalidating_lineage(config, lineage, original_server):
|
|
"Do not renew a valid cert with one from a staging server!"
|
|
def _is_staging(srv):
|
|
return srv == constants.STAGING_URI or "staging" in srv
|
|
|
|
# Some lineages may have begun with --staging, but then had production certs
|
|
# added to them
|
|
latest_cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM,
|
|
open(lineage.cert).read())
|
|
# all our test certs are from happy hacker fake CA, though maybe one day
|
|
# we should test more methodically
|
|
now_valid = "fake" not in repr(latest_cert.get_issuer()).lower()
|
|
|
|
if _is_staging(config.server):
|
|
if not _is_staging(original_server) or now_valid:
|
|
if not config.break_my_certs:
|
|
names = ", ".join(lineage.names())
|
|
raise errors.Error(
|
|
"You've asked to renew/replace a seemingly valid certificate with "
|
|
"a test certificate (domains: {0}). We will not do that "
|
|
"unless you use the --break-my-certs flag!".format(names))
|
|
|
|
|
|
def diagnose_configurator_problem(cfg_type, requested, plugins):
|
|
"""
|
|
Raise the most helpful error message about a plugin being unavailable
|
|
|
|
:param str cfg_type: either "installer" or "authenticator"
|
|
:param str requested: the plugin that was requested
|
|
:param .PluginsRegistry plugins: available plugins
|
|
|
|
:raises error.PluginSelectionError: if there was a problem
|
|
"""
|
|
|
|
if requested:
|
|
if requested not in plugins:
|
|
msg = "The requested {0} plugin does not appear to be installed".format(requested)
|
|
else:
|
|
msg = ("The {0} plugin is not working; there may be problems with "
|
|
"your existing configuration.\nThe error was: {1!r}"
|
|
.format(requested, plugins[requested].problem))
|
|
elif cfg_type == "installer":
|
|
if os.path.exists("/etc/debian_version"):
|
|
# Debian... installers are at least possible
|
|
msg = ('No installers seem to be present and working on your system; '
|
|
'fix that or try running letsencrypt with the "certonly" command')
|
|
else:
|
|
# XXX update this logic as we make progress on #788 and nginx support
|
|
msg = ('No installers are available on your OS yet; try running '
|
|
'"letsencrypt-auto certonly" to get a cert you can install manually')
|
|
else:
|
|
msg = "{0} could not be determined or is not installed".format(cfg_type)
|
|
raise errors.PluginSelectionError(msg)
|
|
|
|
|
|
def set_configurator(previously, now):
|
|
"""
|
|
Setting configurators multiple ways is okay, as long as they all agree
|
|
:param str previously: previously identified request for the installer/authenticator
|
|
:param str requested: the request currently being processed
|
|
"""
|
|
if now is None:
|
|
# we're not actually setting anything
|
|
return previously
|
|
if previously:
|
|
if previously != now:
|
|
msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}"
|
|
raise errors.PluginSelectionError(msg.format(repr(previously), repr(now)))
|
|
return now
|
|
|
|
|
|
def cli_plugin_requests(config):
|
|
"""
|
|
Figure out which plugins the user requested with CLI and config options
|
|
|
|
:returns: (requested authenticator string or None, requested installer string or None)
|
|
:rtype: tuple
|
|
"""
|
|
req_inst = req_auth = config.configurator
|
|
req_inst = set_configurator(req_inst, config.installer)
|
|
req_auth = set_configurator(req_auth, config.authenticator)
|
|
if config.nginx:
|
|
req_inst = set_configurator(req_inst, "nginx")
|
|
req_auth = set_configurator(req_auth, "nginx")
|
|
if config.apache:
|
|
req_inst = set_configurator(req_inst, "apache")
|
|
req_auth = set_configurator(req_auth, "apache")
|
|
if config.standalone:
|
|
req_auth = set_configurator(req_auth, "standalone")
|
|
if config.webroot:
|
|
req_auth = set_configurator(req_auth, "webroot")
|
|
if config.manual:
|
|
req_auth = set_configurator(req_auth, "manual")
|
|
logger.debug("Requested authenticator %s and installer %s", req_auth, req_inst)
|
|
return req_auth, req_inst
|
|
|
|
|
|
noninstaller_plugins = ["webroot", "manual", "standalone"]
|
|
|
|
|
|
def choose_configurator_plugins(config, plugins, verb):
|
|
"""
|
|
Figure out which configurator we're going to use, modifies
|
|
config.authenticator and config.istaller strings to reflect that choice if
|
|
necessary.
|
|
|
|
:raises errors.PluginSelectionError if there was a problem
|
|
|
|
:returns: (an `IAuthenticator` or None, an `IInstaller` or None)
|
|
:rtype: tuple
|
|
"""
|
|
|
|
req_auth, req_inst = cli_plugin_requests(config)
|
|
|
|
# Which plugins do we need?
|
|
if verb == "run":
|
|
need_inst = need_auth = True
|
|
if req_auth in noninstaller_plugins and not req_inst:
|
|
msg = ('With the {0} plugin, you probably want to use the "certonly" command, eg:{1}'
|
|
'{1} {2} certonly --{0}{1}{1}'
|
|
'(Alternatively, add a --installer flag. See https://eff.org/letsencrypt-plugins'
|
|
'{1} and "--help plugins" for more information.)'.format(
|
|
req_auth, os.linesep, cli_command))
|
|
|
|
raise errors.MissingCommandlineFlag(msg)
|
|
else:
|
|
need_inst = need_auth = False
|
|
if verb == "certonly":
|
|
need_auth = True
|
|
if verb == "install":
|
|
need_inst = True
|
|
if config.authenticator:
|
|
logger.warn("Specifying an authenticator doesn't make sense in install mode")
|
|
|
|
# Try to meet the user's request and/or ask them to pick plugins
|
|
authenticator = installer = None
|
|
if verb == "run" and req_auth == req_inst:
|
|
# Unless the user has explicitly asked for different auth/install,
|
|
# only consider offering a single choice
|
|
authenticator = installer = display_ops.pick_configurator(config, req_inst, plugins)
|
|
else:
|
|
if need_inst or req_inst:
|
|
installer = display_ops.pick_installer(config, req_inst, plugins)
|
|
if need_auth:
|
|
authenticator = display_ops.pick_authenticator(config, req_auth, plugins)
|
|
logger.debug("Selected authenticator %s and installer %s", authenticator, installer)
|
|
|
|
# Report on any failures
|
|
if need_inst and not installer:
|
|
diagnose_configurator_problem("installer", req_inst, plugins)
|
|
if need_auth and not authenticator:
|
|
diagnose_configurator_problem("authenticator", req_auth, plugins)
|
|
|
|
record_chosen_plugins(config, plugins, authenticator, installer)
|
|
return installer, authenticator
|
|
|
|
|
|
def record_chosen_plugins(config, plugins, auth, inst):
|
|
"Update the config entries to reflect the plugins we actually selected."
|
|
cn = config.namespace
|
|
cn.authenticator = plugins.find_init(auth).name if auth else "none"
|
|
cn.installer = plugins.find_init(inst).name if inst else "none"
|
|
|
|
|
|
# TODO: Make run as close to auth + install as possible
|
|
# Possible difficulties: config.csr was hacked into auth
|
|
def run(config, plugins): # pylint: disable=too-many-branches,too-many-locals
|
|
"""Obtain a certificate and install."""
|
|
try:
|
|
installer, authenticator = choose_configurator_plugins(config, plugins, "run")
|
|
except errors.PluginSelectionError as e:
|
|
return e.message
|
|
|
|
domains = _find_domains(config, installer)
|
|
|
|
# TODO: Handle errors from _init_le_client?
|
|
le_client = _init_le_client(config, authenticator, installer)
|
|
|
|
lineage, action = _auth_from_domains(le_client, config, domains)
|
|
|
|
le_client.deploy_certificate(
|
|
domains, lineage.privkey, lineage.cert,
|
|
lineage.chain, lineage.fullchain)
|
|
|
|
le_client.enhance_config(domains, config)
|
|
|
|
if len(lineage.available_versions("cert")) == 1:
|
|
display_ops.success_installation(domains)
|
|
else:
|
|
display_ops.success_renewal(domains, action)
|
|
|
|
_suggest_donation_if_appropriate(config, action)
|
|
|
|
|
|
def obtain_cert(config, plugins, lineage=None):
|
|
"""Implements "certonly": authenticate & obtain cert, but do not install it."""
|
|
# pylint: disable=too-many-locals
|
|
try:
|
|
# installers are used in auth mode to determine domain names
|
|
installer, authenticator = choose_configurator_plugins(config, plugins, "certonly")
|
|
except errors.PluginSelectionError as e:
|
|
logger.info("Could not choose appropriate plugin: %s", e)
|
|
raise
|
|
|
|
# TODO: Handle errors from _init_le_client?
|
|
le_client = _init_le_client(config, authenticator, installer)
|
|
|
|
action = "newcert"
|
|
# This is a special case; cert and chain are simply saved
|
|
if config.csr is not None:
|
|
assert lineage is None, "Did not expect a CSR with a RenewableCert"
|
|
csr, typ = config.actual_csr
|
|
certr, chain = le_client.obtain_certificate_from_csr(config.domains, csr, typ)
|
|
if config.dry_run:
|
|
logger.info(
|
|
"Dry run: skipping saving certificate to %s", config.cert_path)
|
|
else:
|
|
cert_path, _, cert_fullchain = le_client.save_certificate(
|
|
certr, chain, config.cert_path, config.chain_path, config.fullchain_path)
|
|
_report_new_cert(cert_path, cert_fullchain)
|
|
else:
|
|
domains = _find_domains(config, installer)
|
|
_, action = _auth_from_domains(le_client, config, domains, lineage)
|
|
|
|
if config.dry_run:
|
|
_report_successful_dry_run(config)
|
|
elif config.verb == "renew":
|
|
if installer is None:
|
|
# Tell the user that the server was not restarted.
|
|
print("new certificate deployed without reload, fullchain is",
|
|
lineage.fullchain)
|
|
else:
|
|
# In case of a renewal, reload server to pick up new certificate.
|
|
# In principle we could have a configuration option to inhibit this
|
|
# from happening.
|
|
installer.restart()
|
|
print("new certificate deployed with reload of",
|
|
config.installer, "server; fullchain is", lineage.fullchain)
|
|
_suggest_donation_if_appropriate(config, action)
|
|
|
|
|
|
def install(config, plugins):
|
|
"""Install a previously obtained cert in a server."""
|
|
# XXX: Update for renewer/RenewableCert
|
|
# FIXME: be consistent about whether errors are raised or returned from
|
|
# this function ...
|
|
|
|
try:
|
|
installer, _ = choose_configurator_plugins(config, plugins, "install")
|
|
except errors.PluginSelectionError as e:
|
|
return e.message
|
|
|
|
domains = _find_domains(config, installer)
|
|
le_client = _init_le_client(config, authenticator=None, installer=installer)
|
|
assert config.cert_path is not None # required=True in the subparser
|
|
le_client.deploy_certificate(
|
|
domains, config.key_path, config.cert_path, config.chain_path,
|
|
config.fullchain_path)
|
|
le_client.enhance_config(domains, config)
|
|
|
|
|
|
def _set_by_cli(var):
|
|
"""
|
|
Return True if a particular config variable has been set by the user
|
|
(CLI or config file) including if the user explicitly set it to the
|
|
default. Returns False if the variable was assigned a default value.
|
|
"""
|
|
detector = _set_by_cli.detector
|
|
if detector is None:
|
|
# Setup on first run: `detector` is a weird version of config in which
|
|
# the default value of every attribute is wrangled to be boolean-false
|
|
plugins = plugins_disco.PluginsRegistry.find_all()
|
|
# reconstructed_args == sys.argv[1:], or whatever was passed to main()
|
|
reconstructed_args = _parser.args + [_parser.verb]
|
|
detector = _set_by_cli.detector = prepare_and_parse_args(
|
|
plugins, reconstructed_args, detect_defaults=True)
|
|
# propagate plugin requests: eg --standalone modifies config.authenticator
|
|
auth, inst = cli_plugin_requests(detector)
|
|
detector.authenticator = auth if auth else ""
|
|
detector.installer = inst if inst else ""
|
|
logger.debug("Default Detector is %r", detector)
|
|
|
|
try:
|
|
# Is detector.var something that isn't false?
|
|
change_detected = getattr(detector, var)
|
|
except AttributeError:
|
|
logger.warning("Missing default analysis for %r", var)
|
|
return False
|
|
|
|
if change_detected:
|
|
return True
|
|
# Special case: we actually want account to be set to "" if the server
|
|
# the account was on has changed
|
|
elif var == "account" and (detector.server or detector.dry_run or detector.staging):
|
|
return True
|
|
# Special case: vars like --no-redirect that get set True -> False
|
|
# default to None; False means they were set
|
|
elif var in detector.store_false_vars and change_detected is not None:
|
|
return True
|
|
else:
|
|
return False
|
|
# static housekeeping var
|
|
_set_by_cli.detector = None
|
|
|
|
def _restore_required_config_elements(config, renewalparams):
|
|
"""Sets non-plugin specific values in config from renewalparams
|
|
|
|
:param configuration.NamespaceConfig config: configuration for the
|
|
current lineage
|
|
:param configobj.Section renewalparams: parameters from the renewal
|
|
configuration file that defines this lineage
|
|
|
|
"""
|
|
# string-valued items to add if they're present
|
|
for config_item in STR_CONFIG_ITEMS:
|
|
if config_item in renewalparams and not _set_by_cli(config_item):
|
|
value = renewalparams[config_item]
|
|
# Unfortunately, we've lost type information from ConfigObj,
|
|
# so we don't know if the original was NoneType or str!
|
|
if value == "None":
|
|
value = None
|
|
setattr(config.namespace, config_item, value)
|
|
# int-valued items to add if they're present
|
|
for config_item in INT_CONFIG_ITEMS:
|
|
if config_item in renewalparams and not _set_by_cli(config_item):
|
|
try:
|
|
value = int(renewalparams[config_item])
|
|
setattr(config.namespace, config_item, value)
|
|
except ValueError:
|
|
raise errors.Error(
|
|
"Expected a numeric value for {0}".format(config_item))
|
|
|
|
|
|
def _restore_plugin_configs(config, renewalparams):
|
|
"""Sets plugin specific values in config from renewalparams
|
|
|
|
:param configuration.NamespaceConfig config: configuration for the
|
|
current lineage
|
|
:param configobj.Section renewalparams: Parameters from the renewal
|
|
configuration file that defines this lineage
|
|
|
|
"""
|
|
# Now use parser to get plugin-prefixed items with correct types
|
|
# XXX: the current approach of extracting only prefixed items
|
|
# related to the actually-used installer and authenticator
|
|
# works as long as plugins don't need to read plugin-specific
|
|
# variables set by someone else (e.g., assuming Apache
|
|
# configurator doesn't need to read webroot_ variables).
|
|
# Note: if a parameter that used to be defined in the parser is no
|
|
# longer defined, stored copies of that parameter will be
|
|
# deserialized as strings by this logic even if they were
|
|
# originally meant to be some other type.
|
|
if renewalparams["authenticator"] == "webroot":
|
|
_restore_webroot_config(config, renewalparams)
|
|
plugin_prefixes = []
|
|
else:
|
|
plugin_prefixes = [renewalparams["authenticator"]]
|
|
|
|
if renewalparams.get("installer", None) is not None:
|
|
plugin_prefixes.append(renewalparams["installer"])
|
|
for plugin_prefix in set(plugin_prefixes):
|
|
for config_item, config_value in renewalparams.iteritems():
|
|
if config_item.startswith(plugin_prefix + "_") and not _set_by_cli(config_item):
|
|
# Values None, True, and False need to be treated specially,
|
|
# As they don't get parsed correctly based on type
|
|
if config_value in ("None", "True", "False"):
|
|
# bool("False") == True
|
|
# pylint: disable=eval-used
|
|
setattr(config.namespace, config_item, eval(config_value))
|
|
continue
|
|
for action in _parser.parser._actions: # pylint: disable=protected-access
|
|
if action.type is not None and action.dest == config_item:
|
|
setattr(config.namespace, config_item,
|
|
action.type(config_value))
|
|
break
|
|
else:
|
|
setattr(config.namespace, config_item, str(config_value))
|
|
|
|
def _restore_webroot_config(config, renewalparams):
|
|
"""
|
|
webroot_map is, uniquely, a dict, and the general-purpose configuration
|
|
restoring logic is not able to correctly parse it from the serialized
|
|
form.
|
|
"""
|
|
if "webroot_map" in renewalparams:
|
|
# if the user does anything that would create a new webroot map on the
|
|
# CLI, don't use the old one
|
|
if not (_set_by_cli("webroot_map") or _set_by_cli("webroot_path")):
|
|
setattr(config.namespace, "webroot_map", renewalparams["webroot_map"])
|
|
elif "webroot_path" in renewalparams:
|
|
logger.info("Ancient renewal conf file without webroot-map, restoring webroot-path")
|
|
wp = renewalparams["webroot_path"]
|
|
if isinstance(wp, str): # prior to 0.1.0, webroot_path was a string
|
|
wp = [wp]
|
|
setattr(config.namespace, "webroot_path", wp)
|
|
|
|
|
|
def _reconstitute(config, full_path):
|
|
"""Try to instantiate a RenewableCert, updating config with relevant items.
|
|
|
|
This is specifically for use in renewal and enforces several checks
|
|
and policies to ensure that we can try to proceed with the renwal
|
|
request. The config argument is modified by including relevant options
|
|
read from the renewal configuration file.
|
|
|
|
:param configuration.NamespaceConfig config: configuration for the
|
|
current lineage
|
|
:param str full_path: Absolute path to the configuration file that
|
|
defines this lineage
|
|
|
|
:returns: the RenewableCert object or None if a fatal error occurred
|
|
:rtype: `storage.RenewableCert` or NoneType
|
|
|
|
"""
|
|
try:
|
|
renewal_candidate = storage.RenewableCert(
|
|
full_path, configuration.RenewerConfiguration(config))
|
|
except (errors.CertStorageError, IOError):
|
|
logger.warning("Renewal configuration file %s is broken. Skipping.", full_path)
|
|
logger.debug("Traceback was:\n%s", traceback.format_exc())
|
|
return None
|
|
if "renewalparams" not in renewal_candidate.configuration:
|
|
logger.warning("Renewal configuration file %s lacks "
|
|
"renewalparams. Skipping.", full_path)
|
|
return None
|
|
renewalparams = renewal_candidate.configuration["renewalparams"]
|
|
if "authenticator" not in renewalparams:
|
|
logger.warning("Renewal configuration file %s does not specify "
|
|
"an authenticator. Skipping.", full_path)
|
|
return None
|
|
# Now restore specific values along with their data types, if
|
|
# those elements are present.
|
|
try:
|
|
_restore_required_config_elements(config, renewalparams)
|
|
_restore_plugin_configs(config, renewalparams)
|
|
except (ValueError, errors.Error) as error:
|
|
logger.warning(
|
|
"An error occured while parsing %s. The error was %s. "
|
|
"Skipping the file.", full_path, error.message)
|
|
logger.debug("Traceback was:\n%s", traceback.format_exc())
|
|
return None
|
|
|
|
try:
|
|
for d in renewal_candidate.names():
|
|
_process_domain(config, d)
|
|
except errors.ConfigurationError as error:
|
|
logger.warning("Renewal configuration file %s references a cert "
|
|
"that contains an invalid domain name. The problem "
|
|
"was: %s. Skipping.", full_path, error)
|
|
return None
|
|
|
|
return renewal_candidate
|
|
|
|
def _renewal_conf_files(config):
|
|
"""Return /path/to/*.conf in the renewal conf directory"""
|
|
return glob.glob(os.path.join(config.renewal_configs_dir, "*.conf"))
|
|
|
|
|
|
def _renew_describe_results(config, renew_successes, renew_failures,
|
|
renew_skipped, parse_failures):
|
|
status = lambda x, msg: " " + "\n ".join(i + " (" + msg +")" for i in x)
|
|
if config.dry_run:
|
|
print("** DRY RUN: simulating 'letsencrypt renew' close to cert expiry")
|
|
print("** (The test certificates below have not been saved.)")
|
|
print()
|
|
if renew_skipped:
|
|
print("The following certs are not due for renewal yet:")
|
|
print(status(renew_skipped, "skipped"))
|
|
if not renew_successes and not renew_failures:
|
|
print("No renewals were attempted.")
|
|
elif renew_successes and not renew_failures:
|
|
print("Congratulations, all renewals succeeded. The following certs "
|
|
"have been renewed:")
|
|
print(status(renew_successes, "success"))
|
|
elif renew_failures and not renew_successes:
|
|
print("All renewal attempts failed. The following certs could not be "
|
|
"renewed:")
|
|
print(status(renew_failures, "failure"))
|
|
elif renew_failures and renew_successes:
|
|
print("The following certs were successfully renewed:")
|
|
print(status(renew_successes, "success"))
|
|
print("\nThe following certs could not be renewed:")
|
|
print(status(renew_failures, "failure"))
|
|
|
|
if parse_failures:
|
|
print("\nAdditionally, the following renewal configuration files "
|
|
"were invalid: ")
|
|
print(status(parse_failures, "parsefail"))
|
|
|
|
if config.dry_run:
|
|
print("** DRY RUN: simulating 'letsencrypt renew' close to cert expiry")
|
|
print("** (The test certificates above have not been saved.)")
|
|
|
|
|
|
def renew(config, unused_plugins):
|
|
"""Renew previously-obtained certificates."""
|
|
|
|
if config.domains != []:
|
|
raise errors.Error("Currently, the renew verb is only capable of "
|
|
"renewing all installed certificates that are due "
|
|
"to be renewed; individual domains cannot be "
|
|
"specified with this action. If you would like to "
|
|
"renew specific certificates, use the certonly "
|
|
"command. The renew verb may provide other options "
|
|
"for selecting certificates to renew in the future.")
|
|
renewer_config = configuration.RenewerConfiguration(config)
|
|
renew_successes = []
|
|
renew_failures = []
|
|
renew_skipped = []
|
|
parse_failures = []
|
|
for renewal_file in _renewal_conf_files(renewer_config):
|
|
print("Processing " + renewal_file)
|
|
lineage_config = copy.deepcopy(config)
|
|
|
|
# Note that this modifies config (to add back the configuration
|
|
# elements from within the renewal configuration file).
|
|
try:
|
|
renewal_candidate = _reconstitute(lineage_config, renewal_file)
|
|
except Exception as e: # pylint: disable=broad-except
|
|
logger.warning("Renewal configuration file %s produced an "
|
|
"unexpected error: %s. Skipping.", renewal_file, e)
|
|
logger.debug("Traceback was:\n%s", traceback.format_exc())
|
|
parse_failures.append(renewal_file)
|
|
continue
|
|
|
|
try:
|
|
if renewal_candidate is None:
|
|
parse_failures.append(renewal_file)
|
|
else:
|
|
# XXX: ensure that each call here replaces the previous one
|
|
zope.component.provideUtility(lineage_config)
|
|
if _should_renew(lineage_config, renewal_candidate):
|
|
plugins = plugins_disco.PluginsRegistry.find_all()
|
|
obtain_cert(lineage_config, plugins, renewal_candidate)
|
|
renew_successes.append(renewal_candidate.fullchain)
|
|
else:
|
|
renew_skipped.append(renewal_candidate.fullchain)
|
|
except Exception as e: # pylint: disable=broad-except
|
|
# obtain_cert (presumably) encountered an unanticipated problem.
|
|
logger.warning("Attempting to renew cert from %s produced an "
|
|
"unexpected error: %s. Skipping.", renewal_file, e)
|
|
logger.debug("Traceback was:\n%s", traceback.format_exc())
|
|
renew_failures.append(renewal_candidate.fullchain)
|
|
|
|
# Describe all the results
|
|
_renew_describe_results(config, renew_successes, renew_failures,
|
|
renew_skipped, parse_failures)
|
|
|
|
if renew_failures or parse_failures:
|
|
raise errors.Error("{0} renew failure(s), {1} parse failure(s)".format(
|
|
len(renew_failures), len(parse_failures)))
|
|
else:
|
|
logger.debug("no renewal failures")
|
|
|
|
|
|
def revoke(config, unused_plugins): # TODO: coop with renewal config
|
|
"""Revoke a previously obtained certificate."""
|
|
# For user-agent construction
|
|
config.namespace.installer = config.namespace.authenticator = "none"
|
|
if config.key_path is not None: # revocation by cert key
|
|
logger.debug("Revoking %s using cert key %s",
|
|
config.cert_path[0], config.key_path[0])
|
|
key = jose.JWK.load(config.key_path[1])
|
|
else: # revocation by account key
|
|
logger.debug("Revoking %s using Account Key", config.cert_path[0])
|
|
acc, _ = _determine_account(config)
|
|
key = acc.key
|
|
acme = client.acme_from_config_key(config, key)
|
|
cert = crypto_util.pyopenssl_load_certificate(config.cert_path[1])[0]
|
|
acme.revoke(jose.ComparableX509(cert))
|
|
|
|
|
|
def rollback(config, plugins):
|
|
"""Rollback server configuration changes made during install."""
|
|
client.rollback(config.installer, config.checkpoints, config, plugins)
|
|
|
|
|
|
def config_changes(config, unused_plugins):
|
|
"""Show changes made to server config during installation
|
|
|
|
View checkpoints and associated configuration changes.
|
|
|
|
"""
|
|
client.view_config_changes(config)
|
|
|
|
|
|
def plugins_cmd(config, plugins): # TODO: Use IDisplay rather than print
|
|
"""List server software plugins."""
|
|
logger.debug("Expected interfaces: %s", config.ifaces)
|
|
|
|
ifaces = [] if config.ifaces is None else config.ifaces
|
|
filtered = plugins.visible().ifaces(ifaces)
|
|
logger.debug("Filtered plugins: %r", filtered)
|
|
|
|
if not config.init and not config.prepare:
|
|
print(str(filtered))
|
|
return
|
|
|
|
filtered.init(config)
|
|
verified = filtered.verify(ifaces)
|
|
logger.debug("Verified plugins: %r", verified)
|
|
|
|
if not config.prepare:
|
|
print(str(verified))
|
|
return
|
|
|
|
verified.prepare()
|
|
available = verified.available()
|
|
logger.debug("Prepared plugins: %s", available)
|
|
print(str(available))
|
|
|
|
|
|
def read_file(filename, mode="rb"):
|
|
"""Returns the given file's contents.
|
|
|
|
:param str filename: path to file
|
|
:param str mode: open mode (see `open`)
|
|
|
|
:returns: absolute path of filename and its contents
|
|
:rtype: tuple
|
|
|
|
:raises argparse.ArgumentTypeError: File does not exist or is not readable.
|
|
|
|
"""
|
|
try:
|
|
filename = os.path.abspath(filename)
|
|
return filename, open(filename, mode).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, hidden=False):
|
|
"""Help message for `.IConfig` attribute."""
|
|
if hidden:
|
|
return argparse.SUPPRESS
|
|
else:
|
|
return interfaces.IConfig[name].__doc__
|
|
|
|
|
|
class SilentParser(object): # pylint: disable=too-few-public-methods
|
|
"""Silent wrapper around argparse.
|
|
|
|
A mini parser wrapper that doesn't print help for its
|
|
arguments. This is needed for the use of callbacks to define
|
|
arguments within plugins.
|
|
|
|
"""
|
|
def __init__(self, parser):
|
|
self.parser = parser
|
|
|
|
def add_argument(self, *args, **kwargs):
|
|
"""Wrap, but silence help"""
|
|
kwargs["help"] = argparse.SUPPRESS
|
|
self.parser.add_argument(*args, **kwargs)
|
|
|
|
|
|
class HelpfulArgumentParser(object):
|
|
"""Argparse Wrapper.
|
|
|
|
This class wraps argparse, adding the ability to make --help less
|
|
verbose, and request help on specific subcategories at a time, eg
|
|
'letsencrypt --help security' for security options.
|
|
|
|
"""
|
|
|
|
# Maps verbs/subcommands to the functions that implement them
|
|
VERBS = {"auth": obtain_cert, "certonly": obtain_cert,
|
|
"config_changes": config_changes, "everything": run,
|
|
"install": install, "plugins": plugins_cmd, "renew": renew,
|
|
"revoke": revoke, "rollback": rollback, "run": run}
|
|
|
|
# List of topics for which additional help can be provided
|
|
HELP_TOPICS = ["all", "security",
|
|
"paths", "automation", "testing"] + VERBS.keys()
|
|
|
|
def __init__(self, args, plugins, detect_defaults=False):
|
|
plugin_names = [name for name, _p in plugins.iteritems()]
|
|
self.help_topics = self.HELP_TOPICS + plugin_names + [None]
|
|
usage, short_usage = usage_strings(plugins)
|
|
self.parser = configargparse.ArgParser(
|
|
usage=short_usage,
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
args_for_setting_config_path=["-c", "--config"],
|
|
default_config_files=flag_default("config_files"))
|
|
|
|
# This is the only way to turn off overly verbose config flag documentation
|
|
self.parser._add_config_file_help = False # pylint: disable=protected-access
|
|
self.silent_parser = SilentParser(self.parser)
|
|
|
|
# This setting attempts to force all default values to things that are
|
|
# pythonically false; it is used to detect when values have been
|
|
# explicitly set by the user, including when they are set to their
|
|
# normal default value
|
|
self.detect_defaults = detect_defaults
|
|
if detect_defaults:
|
|
self.store_false_vars = {} # vars that use "store_false"
|
|
|
|
self.args = args
|
|
self.determine_verb()
|
|
help1 = self.prescan_for_flag("-h", self.help_topics)
|
|
help2 = self.prescan_for_flag("--help", self.help_topics)
|
|
assert max(True, "a") == "a", "Gravity changed direction"
|
|
self.help_arg = max(help1, help2)
|
|
if self.help_arg is True:
|
|
# just --help with no topic; avoid argparse altogether
|
|
print(usage)
|
|
sys.exit(0)
|
|
self.visible_topics = self.determine_help_topics(self.help_arg)
|
|
self.groups = {} # elements are added by .add_group()
|
|
|
|
def parse_args(self):
|
|
"""Parses command line arguments and returns the result.
|
|
|
|
:returns: parsed command line arguments
|
|
:rtype: argparse.Namespace
|
|
|
|
"""
|
|
parsed_args = self.parser.parse_args(self.args)
|
|
parsed_args.func = self.VERBS[self.verb]
|
|
parsed_args.verb = self.verb
|
|
|
|
# Do any post-parsing homework here
|
|
|
|
# we get domains from -d, but also from the webroot map...
|
|
if parsed_args.webroot_map:
|
|
for domain in parsed_args.webroot_map.keys():
|
|
if domain not in parsed_args.domains:
|
|
parsed_args.domains.append(domain)
|
|
|
|
if parsed_args.staging or parsed_args.dry_run:
|
|
if parsed_args.server not in (flag_default("server"), constants.STAGING_URI):
|
|
conflicts = ["--staging"] if parsed_args.staging else []
|
|
conflicts += ["--dry-run"] if parsed_args.dry_run else []
|
|
if not self.detect_defaults:
|
|
raise errors.Error("--server value conflicts with {0}".format(
|
|
" and ".join(conflicts)))
|
|
|
|
parsed_args.server = constants.STAGING_URI
|
|
|
|
if parsed_args.dry_run:
|
|
if self.verb not in ["certonly", "renew"]:
|
|
raise errors.Error("--dry-run currently only works with the "
|
|
"'certonly' or 'renew' subcommands (%r)" % self.verb)
|
|
parsed_args.break_my_certs = parsed_args.staging = True
|
|
if glob.glob(os.path.join(parsed_args.config_dir, constants.ACCOUNTS_DIR, "*")):
|
|
# The user has a prod account, but might not have a staging
|
|
# one; we don't want to start trying to perform interactive registration
|
|
parsed_args.agree_tos = True
|
|
parsed_args.register_unsafely_without_email = True
|
|
|
|
if parsed_args.csr:
|
|
self.handle_csr(parsed_args)
|
|
|
|
if self.detect_defaults: # plumbing
|
|
parsed_args.store_false_vars = self.store_false_vars
|
|
|
|
return parsed_args
|
|
|
|
def handle_csr(self, parsed_args):
|
|
"""
|
|
Process a --csr flag. This needs to happen early enough that the
|
|
webroot plugin can know about the calls to _process_domain
|
|
"""
|
|
if parsed_args.verb != "certonly":
|
|
raise errors.Error("Currently, a CSR file may only be specified "
|
|
"when obtaining a new or replacement "
|
|
"via the certonly command. Please try the "
|
|
"certonly command instead.")
|
|
|
|
try:
|
|
csr = le_util.CSR(file=parsed_args.csr[0], data=parsed_args.csr[1], form="der")
|
|
typ = OpenSSL.crypto.FILETYPE_ASN1
|
|
domains = crypto_util.get_sans_from_csr(csr.data, OpenSSL.crypto.FILETYPE_ASN1)
|
|
except OpenSSL.crypto.Error:
|
|
try:
|
|
e1 = traceback.format_exc()
|
|
typ = OpenSSL.crypto.FILETYPE_PEM
|
|
csr = le_util.CSR(file=parsed_args.csr[0], data=parsed_args.csr[1], form="pem")
|
|
domains = crypto_util.get_sans_from_csr(csr.data, typ)
|
|
except OpenSSL.crypto.Error:
|
|
logger.debug("DER CSR parse error %s", e1)
|
|
logger.debug("PEM CSR parse error %s", traceback.format_exc())
|
|
raise errors.Error("Failed to parse CSR file: {0}".format(parsed_args.csr[0]))
|
|
for d in domains:
|
|
_process_domain(parsed_args, d)
|
|
|
|
for d in domains:
|
|
sanitised = le_util.enforce_domain_sanity(d)
|
|
if d.lower() != sanitised:
|
|
raise errors.ConfigurationError(
|
|
"CSR domain {0} needs to be sanitised to {1}.".format(d, sanitised))
|
|
|
|
if not domains:
|
|
# TODO: add CN to domains instead:
|
|
raise errors.Error(
|
|
"Unfortunately, your CSR %s needs to have a SubjectAltName for every domain"
|
|
% parsed_args.csr[0])
|
|
|
|
parsed_args.actual_csr = (csr, typ)
|
|
csr_domains, config_domains = set(domains), set(parsed_args.domains)
|
|
if csr_domains != config_domains:
|
|
raise errors.ConfigurationError(
|
|
"Inconsistent domain requests:\nFrom the CSR: {0}\nFrom command line/config: {1}"
|
|
.format(", ".join(csr_domains), ", ".join(config_domains)))
|
|
|
|
|
|
def determine_verb(self):
|
|
"""Determines the verb/subcommand provided by the user.
|
|
|
|
This function works around some of the limitations of argparse.
|
|
|
|
"""
|
|
if "-h" in self.args or "--help" in self.args:
|
|
# all verbs double as help arguments; don't get them confused
|
|
self.verb = "help"
|
|
return
|
|
|
|
for i, token in enumerate(self.args):
|
|
if token in self.VERBS:
|
|
verb = token
|
|
if verb == "auth":
|
|
verb = "certonly"
|
|
if verb == "everything":
|
|
verb = "run"
|
|
self.verb = verb
|
|
self.args.pop(i)
|
|
return
|
|
|
|
self.verb = "run"
|
|
|
|
def prescan_for_flag(self, flag, possible_arguments):
|
|
"""Checks cli input for flags.
|
|
|
|
Check for a flag, which accepts a fixed set of possible arguments, in
|
|
the command line; we will use this information to configure argparse's
|
|
help correctly. Return the flag's argument, if it has one that matches
|
|
the sequence @possible_arguments; otherwise return whether the flag is
|
|
present.
|
|
|
|
"""
|
|
if flag not in self.args:
|
|
return False
|
|
pos = self.args.index(flag)
|
|
try:
|
|
nxt = self.args[pos + 1]
|
|
if nxt in possible_arguments:
|
|
return nxt
|
|
except IndexError:
|
|
pass
|
|
return True
|
|
|
|
def add(self, topic, *args, **kwargs):
|
|
"""Add a new command line argument.
|
|
|
|
:param str: help topic this should be listed under, can be None for
|
|
"always documented"
|
|
:param list *args: the names of this argument flag
|
|
:param dict **kwargs: various argparse settings for this argument
|
|
|
|
"""
|
|
|
|
if self.detect_defaults:
|
|
kwargs = self.modify_arg_for_default_detection(self, *args, **kwargs)
|
|
|
|
if self.visible_topics[topic]:
|
|
if topic in self.groups:
|
|
group = self.groups[topic]
|
|
group.add_argument(*args, **kwargs)
|
|
else:
|
|
self.parser.add_argument(*args, **kwargs)
|
|
else:
|
|
kwargs["help"] = argparse.SUPPRESS
|
|
self.parser.add_argument(*args, **kwargs)
|
|
|
|
|
|
def modify_arg_for_default_detection(self, *args, **kwargs):
|
|
"""
|
|
Adding an arg, but ensure that it has a default that evaluates to false,
|
|
so that _set_by_cli can tell if it was set. Only called if detect_defaults==True.
|
|
|
|
:param list *args: the names of this argument flag
|
|
:param dict **kwargs: various argparse settings for this argument
|
|
|
|
:returns: a modified versions of kwargs
|
|
"""
|
|
# argument either doesn't have a default, or the default doesn't
|
|
# isn't Pythonically false
|
|
if kwargs.get("default", True):
|
|
arg_type = kwargs.get("type", None)
|
|
if arg_type == int or kwargs.get("action", "") == "count":
|
|
kwargs["default"] = 0
|
|
elif arg_type == read_file or "-c" in args:
|
|
kwargs["default"] = ""
|
|
kwargs["type"] = str
|
|
else:
|
|
kwargs["default"] = ""
|
|
# This doesn't matter at present (none of the store_false args
|
|
# are renewal-relevant), but implement it for future sanity:
|
|
# detect the setting of args whose presence causes True -> False
|
|
if kwargs.get("action", "") == "store_false":
|
|
kwargs["default"] = None
|
|
for var in args:
|
|
self.store_false_vars[var] = True
|
|
|
|
return kwargs
|
|
|
|
|
|
def add_deprecated_argument(self, argument_name, num_args):
|
|
"""Adds a deprecated argument with the name argument_name.
|
|
|
|
Deprecated arguments are not shown in the help. If they are used
|
|
on the command line, a warning is shown stating that the
|
|
argument is deprecated and no other action is taken.
|
|
|
|
:param str argument_name: Name of deprecated argument.
|
|
:param int nargs: Number of arguments the option takes.
|
|
|
|
"""
|
|
le_util.add_deprecated_argument(
|
|
self.parser.add_argument, argument_name, num_args)
|
|
|
|
def add_group(self, topic, **kwargs):
|
|
"""
|
|
|
|
This has to be called once for every topic; but we leave those calls
|
|
next to the argument definitions for clarity. Return something
|
|
arguments can be added to if necessary, either the parser or an argument
|
|
group.
|
|
|
|
"""
|
|
if self.visible_topics[topic]:
|
|
#print("Adding visible group " + topic)
|
|
group = self.parser.add_argument_group(topic, **kwargs)
|
|
self.groups[topic] = group
|
|
return group
|
|
else:
|
|
#print("Invisible group " + topic)
|
|
return self.silent_parser
|
|
|
|
def add_plugin_args(self, plugins):
|
|
"""
|
|
|
|
Let each of the plugins add its own command line arguments, which
|
|
may or may not be displayed as help topics.
|
|
|
|
"""
|
|
for name, plugin_ep in plugins.iteritems():
|
|
parser_or_group = self.add_group(name, description=plugin_ep.description)
|
|
#print(parser_or_group)
|
|
plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name)
|
|
|
|
def determine_help_topics(self, chosen_topic):
|
|
"""
|
|
|
|
The user may have requested help on a topic, return a dict of which
|
|
topics to display. @chosen_topic has prescan_for_flag's return type
|
|
|
|
:returns: dict
|
|
|
|
"""
|
|
# topics maps each topic to whether it should be documented by
|
|
# argparse on the command line
|
|
if chosen_topic == "auth":
|
|
chosen_topic = "certonly"
|
|
if chosen_topic == "everything":
|
|
chosen_topic = "run"
|
|
if chosen_topic == "all":
|
|
return dict([(t, True) for t in self.help_topics])
|
|
elif not chosen_topic:
|
|
return dict([(t, False) for t in self.help_topics])
|
|
else:
|
|
return dict([(t, t == chosen_topic) for t in self.help_topics])
|
|
|
|
|
|
def prepare_and_parse_args(plugins, args, detect_defaults=False):
|
|
"""Returns parsed command line arguments.
|
|
|
|
:param .PluginsRegistry plugins: available plugins
|
|
:param list args: command line arguments with the program name removed
|
|
|
|
:returns: parsed command line arguments
|
|
:rtype: argparse.Namespace
|
|
|
|
"""
|
|
helpful = HelpfulArgumentParser(args, plugins, detect_defaults)
|
|
|
|
# --help is automatically provided by argparse
|
|
helpful.add(
|
|
None, "-v", "--verbose", dest="verbose_count", action="count",
|
|
default=flag_default("verbose_count"), help="This flag can be used "
|
|
"multiple times to incrementally increase the verbosity of output, "
|
|
"e.g. -vvv.")
|
|
helpful.add(
|
|
None, "-t", "--text", dest="text_mode", action="store_true",
|
|
help="Use the text output instead of the curses UI.")
|
|
helpful.add(
|
|
None, "-n", "--non-interactive", "--noninteractive",
|
|
dest="noninteractive_mode", action="store_true",
|
|
help="Run without ever asking for user input. This may require "
|
|
"additional command line flags; the client will try to explain "
|
|
"which ones are required if it finds one missing")
|
|
helpful.add(
|
|
None, "--dry-run", action="store_true", dest="dry_run",
|
|
help="Perform a test run of the client, obtaining test (invalid) certs"
|
|
" but not saving them to disk. This can currently only be used"
|
|
" with the 'certonly' subcommand.")
|
|
helpful.add(
|
|
None, "--register-unsafely-without-email", action="store_true",
|
|
help="Specifying this flag enables registering an account with no "
|
|
"email address. This is strongly discouraged, because in the "
|
|
"event of key loss or account compromise you will irrevocably "
|
|
"lose access to your account. You will also be unable to receive "
|
|
"notice about impending expiration or revocation of your "
|
|
"certificates. Updates to the Subscriber Agreement will still "
|
|
"affect you, and will be effective 14 days after posting an "
|
|
"update to the web site.")
|
|
helpful.add(None, "-m", "--email", help=config_help("email"))
|
|
# 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")
|
|
helpful.add(None, "-d", "--domains", "--domain", dest="domains",
|
|
metavar="DOMAIN", action=DomainFlagProcessor, default=[],
|
|
help="Domain names to apply. For multiple domains you can use "
|
|
"multiple -d flags or enter a comma separated list of domains "
|
|
"as a parameter.")
|
|
helpful.add_group(
|
|
"automation",
|
|
description="Arguments for automating execution & other tweaks")
|
|
helpful.add(
|
|
"automation", "--keep-until-expiring", "--keep", "--reinstall",
|
|
dest="reinstall", action="store_true",
|
|
help="If the requested cert matches an existing cert, always keep the "
|
|
"existing one until it is due for renewal (for the "
|
|
"'run' subcommand this means reinstall the existing cert)")
|
|
helpful.add(
|
|
"automation", "--expand", action="store_true",
|
|
help="If an existing cert covers some subset of the requested names, "
|
|
"always expand and replace it with the additional names.")
|
|
helpful.add(
|
|
"automation", "--version", action="version",
|
|
version="%(prog)s {0}".format(letsencrypt.__version__),
|
|
help="show program's version number and exit")
|
|
helpful.add(
|
|
"automation", "--force-renewal", "--renew-by-default",
|
|
action="store_true", dest="renew_by_default", help="If a certificate "
|
|
"already exists for the requested domains, renew it now, "
|
|
"regardless of whether it is near expiry. (Often "
|
|
"--keep-until-expiring is more appropriate). Also implies "
|
|
"--expand.")
|
|
helpful.add(
|
|
"automation", "--agree-tos", dest="tos", action="store_true",
|
|
help="Agree to the Let's Encrypt Subscriber Agreement")
|
|
helpful.add(
|
|
"automation", "--account", metavar="ACCOUNT_ID",
|
|
help="Account ID to use")
|
|
helpful.add(
|
|
"automation", "--duplicate", dest="duplicate", action="store_true",
|
|
help="Allow making a certificate lineage that duplicates an existing one "
|
|
"(both can be renewed in parallel)")
|
|
helpful.add(
|
|
"automation", "--os-packages-only", action="store_true",
|
|
help="(letsencrypt-auto only) install OS package dependencies and then stop")
|
|
helpful.add(
|
|
"automation", "--no-self-upgrade", action="store_true",
|
|
help="(letsencrypt-auto only) prevent the letsencrypt-auto script from"
|
|
" upgrading itself to newer released versions")
|
|
|
|
helpful.add_group(
|
|
"testing", description="The following flags are meant for "
|
|
"testing purposes only! Do NOT change them, unless you "
|
|
"really know what you're doing!")
|
|
helpful.add(
|
|
"testing", "--debug", action="store_true",
|
|
help="Show tracebacks in case of errors, and allow letsencrypt-auto "
|
|
"execution on experimental platforms")
|
|
helpful.add(
|
|
"testing", "--no-verify-ssl", action="store_true",
|
|
help=config_help("no_verify_ssl"),
|
|
default=flag_default("no_verify_ssl"))
|
|
helpful.add(
|
|
"testing", "--tls-sni-01-port", type=int,
|
|
default=flag_default("tls_sni_01_port"),
|
|
help=config_help("tls_sni_01_port"))
|
|
helpful.add(
|
|
"testing", "--http-01-port", type=int, dest="http01_port",
|
|
default=flag_default("http01_port"), help=config_help("http01_port"))
|
|
helpful.add(
|
|
"testing", "--break-my-certs", action="store_true",
|
|
help="Be willing to replace or renew valid certs with invalid "
|
|
"(testing/staging) certs")
|
|
helpful.add_group(
|
|
"security", description="Security parameters & server settings")
|
|
helpful.add(
|
|
"security", "--rsa-key-size", type=int, metavar="N",
|
|
default=flag_default("rsa_key_size"), help=config_help("rsa_key_size"))
|
|
helpful.add(
|
|
"security", "--redirect", action="store_true",
|
|
help="Automatically redirect all HTTP traffic to HTTPS for the newly "
|
|
"authenticated vhost.", dest="redirect", default=None)
|
|
helpful.add(
|
|
"security", "--no-redirect", action="store_false",
|
|
help="Do not automatically redirect all HTTP traffic to HTTPS for the newly "
|
|
"authenticated vhost.", dest="redirect", default=None)
|
|
helpful.add(
|
|
"security", "--hsts", action="store_true",
|
|
help="Add the Strict-Transport-Security header to every HTTP response."
|
|
" Forcing browser to use always use SSL for the domain."
|
|
" Defends against SSL Stripping.", dest="hsts", default=False)
|
|
helpful.add(
|
|
"security", "--no-hsts", action="store_false",
|
|
help="Do not automatically add the Strict-Transport-Security header"
|
|
" to every HTTP response.", dest="hsts", default=False)
|
|
helpful.add(
|
|
"security", "--uir", action="store_true",
|
|
help="Add the \"Content-Security-Policy: upgrade-insecure-requests\""
|
|
" header to every HTTP response. Forcing the browser to use"
|
|
" https:// for every http:// resource.", dest="uir", default=None)
|
|
helpful.add(
|
|
"security", "--no-uir", action="store_false",
|
|
help=" Do not automatically set the \"Content-Security-Policy:"
|
|
" upgrade-insecure-requests\" header to every HTTP response.",
|
|
dest="uir", default=None)
|
|
helpful.add(
|
|
"security", "--strict-permissions", action="store_true",
|
|
help="Require that all configuration files are owned by the current "
|
|
"user; only needed if your config is somewhere unsafe like /tmp/")
|
|
|
|
helpful.add_group(
|
|
"renew", description="The 'renew' subcommand will attempt to renew all"
|
|
" certificates (or more precisely, certificate lineages) you have"
|
|
" previously obtained if they are close to expiry, and print a"
|
|
" summary of the results. By default, 'renew' will reuse the options"
|
|
" used to create obtain or most recently successfully renew each"
|
|
" certificate lineage. You can try it with `--dry-run` first. For"
|
|
" more fine-grained control, you can renew individual lineages with"
|
|
" the `certonly` subcommand.")
|
|
|
|
helpful.add_deprecated_argument("--agree-dev-preview", 0)
|
|
|
|
_create_subparsers(helpful)
|
|
_paths_parser(helpful)
|
|
# _plugins_parsing should be the last thing to act upon the main
|
|
# parser (--help should display plugin-specific options last)
|
|
_plugins_parsing(helpful, plugins)
|
|
|
|
if not detect_defaults:
|
|
global _parser # pylint: disable=global-statement
|
|
_parser = helpful
|
|
return helpful.parse_args()
|
|
|
|
|
|
def _create_subparsers(helpful):
|
|
helpful.add_group("certonly", description="Options for modifying how a cert is obtained")
|
|
helpful.add_group("install", description="Options for modifying how a cert is deployed")
|
|
helpful.add_group("revoke", description="Options for revocation of certs")
|
|
helpful.add_group("rollback", description="Options for reverting config changes")
|
|
helpful.add_group("plugins", description="Plugin options")
|
|
helpful.add(
|
|
None, "--user-agent", default=None,
|
|
help="Set a custom user agent string for the client. User agent strings allow "
|
|
"the CA to collect high level statistics about success rates by OS and "
|
|
"plugin. If you wish to hide your server OS version from the Let's "
|
|
'Encrypt server, set this to "".')
|
|
helpful.add("certonly",
|
|
"--csr", type=read_file,
|
|
help="Path to a Certificate Signing Request (CSR) in DER"
|
|
" format; note that the .csr file *must* contain a Subject"
|
|
" Alternative Name field for each domain you want certified."
|
|
" Currently --csr only works with the 'certonly' subcommand'")
|
|
helpful.add("rollback",
|
|
"--checkpoints", type=int, metavar="N",
|
|
default=flag_default("rollback_checkpoints"),
|
|
help="Revert configuration N number of checkpoints.")
|
|
helpful.add("plugins",
|
|
"--init", action="store_true", help="Initialize plugins.")
|
|
helpful.add("plugins",
|
|
"--prepare", action="store_true", help="Initialize and prepare plugins.")
|
|
helpful.add("plugins",
|
|
"--authenticators", action="append_const", dest="ifaces",
|
|
const=interfaces.IAuthenticator, help="Limit to authenticator plugins only.")
|
|
helpful.add("plugins",
|
|
"--installers", action="append_const", dest="ifaces",
|
|
const=interfaces.IInstaller, help="Limit to installer plugins only.")
|
|
|
|
|
|
def _paths_parser(helpful):
|
|
add = helpful.add
|
|
verb = helpful.verb
|
|
if verb == "help":
|
|
verb = helpful.help_arg
|
|
helpful.add_group(
|
|
"paths", description="Arguments changing execution paths & servers")
|
|
|
|
cph = "Path to where cert is saved (with auth --csr), installed from or revoked."
|
|
section = "paths"
|
|
if verb in ("install", "revoke", "certonly"):
|
|
section = verb
|
|
if verb == "certonly":
|
|
add(section, "--cert-path", type=os.path.abspath,
|
|
default=flag_default("auth_cert_path"), help=cph)
|
|
elif verb == "revoke":
|
|
add(section, "--cert-path", type=read_file, required=True, help=cph)
|
|
else:
|
|
add(section, "--cert-path", type=os.path.abspath,
|
|
help=cph, required=(verb == "install"))
|
|
|
|
section = "paths"
|
|
if verb in ("install", "revoke"):
|
|
section = verb
|
|
# revoke --key-path reads a file, install --key-path takes a string
|
|
add(section, "--key-path", required=(verb == "install"),
|
|
type=((verb == "revoke" and read_file) or os.path.abspath),
|
|
help="Path to private key for cert installation "
|
|
"or revocation (if account key is missing)")
|
|
|
|
default_cp = None
|
|
if verb == "certonly":
|
|
default_cp = flag_default("auth_chain_path")
|
|
add("paths", "--fullchain-path", default=default_cp, type=os.path.abspath,
|
|
help="Accompanying path to a full certificate chain (cert plus chain).")
|
|
add("paths", "--chain-path", default=default_cp, type=os.path.abspath,
|
|
help="Accompanying path to a certificate chain.")
|
|
add("paths", "--config-dir", default=flag_default("config_dir"),
|
|
help=config_help("config_dir"))
|
|
add("paths", "--work-dir", default=flag_default("work_dir"),
|
|
help=config_help("work_dir"))
|
|
add("paths", "--logs-dir", default=flag_default("logs_dir"),
|
|
help="Logs directory.")
|
|
add("paths", "--server", default=flag_default("server"),
|
|
help=config_help("server"))
|
|
# overwrites server, handled in HelpfulArgumentParser.parse_args()
|
|
add("testing", "--test-cert", "--staging", action='store_true', dest='staging',
|
|
help='Use the staging server to obtain test (invalid) certs; equivalent'
|
|
' to --server ' + constants.STAGING_URI)
|
|
|
|
|
|
def _plugins_parsing(helpful, plugins):
|
|
helpful.add_group(
|
|
"plugins", description="Let's Encrypt client supports an "
|
|
"extensible plugins architecture. See '%(prog)s plugins' for a "
|
|
"list of all installed plugins and their names. You can force "
|
|
"a particular plugin by setting options provided below. Running "
|
|
"--help <plugin_name> will list flags specific to that plugin.")
|
|
helpful.add(
|
|
"plugins", "-a", "--authenticator", help="Authenticator plugin name.")
|
|
helpful.add(
|
|
"plugins", "-i", "--installer", help="Installer plugin name (also used to find domains).")
|
|
helpful.add(
|
|
"plugins", "--configurator", help="Name of the plugin that is "
|
|
"both an authenticator and an installer. Should not be used "
|
|
"together with --authenticator or --installer.")
|
|
helpful.add("plugins", "--apache", action="store_true",
|
|
help="Obtain and install certs using Apache")
|
|
helpful.add("plugins", "--nginx", action="store_true",
|
|
help="Obtain and install certs using Nginx")
|
|
helpful.add("plugins", "--standalone", action="store_true",
|
|
help='Obtain certs using a "standalone" webserver.')
|
|
helpful.add("plugins", "--manual", action="store_true",
|
|
help='Provide laborious manual instructions for obtaining a cert')
|
|
helpful.add("plugins", "--webroot", action="store_true",
|
|
help='Obtain certs by placing files in a webroot directory.')
|
|
|
|
# things should not be reorder past/pre this comment:
|
|
# plugins_group should be displayed in --help before plugin
|
|
# specific groups (so that plugins_group.description makes sense)
|
|
|
|
helpful.add_plugin_args(plugins)
|
|
|
|
# These would normally be a flag within the webroot plugin, but because
|
|
# they are parsed in conjunction with --domains, they live here for
|
|
# legibility. helpful.add_plugin_ags must be called first to add the
|
|
# "webroot" topic
|
|
helpful.add("webroot", "-w", "--webroot-path", default=[], action=WebrootPathProcessor,
|
|
help="public_html / webroot path. This can be specified multiple times to "
|
|
"handle different domains; each domain will have the webroot path that"
|
|
" preceded it. For instance: `-w /var/www/example -d example.com -d "
|
|
"www.example.com -w /var/www/thing -d thing.net -d m.thing.net`")
|
|
# --webroot-map still has some awkward properties, so it is undocumented
|
|
helpful.add("webroot", "--webroot-map", default={}, action=WebrootMapProcessor,
|
|
help="JSON dictionary mapping domains to webroot paths; this "
|
|
"implies -d for each entry. You may need to escape this "
|
|
"from your shell. E.g.: --webroot-map "
|
|
"""'{"eg1.is,m.eg1.is":"/www/eg1/", "eg2.is":"/www/eg2"}' """
|
|
"This option is merged with, but takes precedence over, "
|
|
"-w / -d entries. At present, if you put webroot-map in "
|
|
"a config file, it needs to be on a single line, like: "
|
|
'webroot-map = {"example.com":"/var/www"}.')
|
|
|
|
|
|
class WebrootPathProcessor(argparse.Action): # pylint: disable=missing-docstring
|
|
def __init__(self, *args, **kwargs):
|
|
self.domain_before_webroot = False
|
|
argparse.Action.__init__(self, *args, **kwargs)
|
|
|
|
def __call__(self, parser, args, webroot, option_string=None):
|
|
"""
|
|
Keep a record of --webroot-path / -w flags during processing, so that
|
|
we know which apply to which -d flags
|
|
"""
|
|
if not args.webroot_path: # first -w flag encountered
|
|
# if any --domain flags preceded the first --webroot-path flag,
|
|
# apply that webroot path to those; subsequent entries in
|
|
# args.webroot_map are filled in by cli.DomainFlagProcessor
|
|
if args.domains:
|
|
self.domain_before_webroot = True
|
|
for d in args.domains:
|
|
args.webroot_map.setdefault(d, webroot)
|
|
elif self.domain_before_webroot:
|
|
# FIXME if you set domains in a args file, you should get a different error
|
|
# here, pointing you to --webroot-map
|
|
raise errors.Error("If you specify multiple webroot paths, one of "
|
|
"them must precede all domain flags")
|
|
args.webroot_path.append(webroot)
|
|
|
|
|
|
def _process_domain(args_or_config, domain_arg, webroot_path=None):
|
|
"""
|
|
Process a new -d flag, helping the webroot plugin construct a map of
|
|
{domain : webrootpath} if -w / --webroot-path is in use
|
|
|
|
:param args_or_config: may be an argparse args object, or a NamespaceConfig object
|
|
:param str domain_arg: a string representing 1+ domains, eg: "eg.is, example.com"
|
|
:param str webroot_path: (optional) the webroot_path for these domains
|
|
|
|
"""
|
|
webroot_path = webroot_path if webroot_path else args_or_config.webroot_path
|
|
|
|
for domain in (d.strip() for d in domain_arg.split(",")):
|
|
domain = le_util.enforce_domain_sanity(domain)
|
|
if domain not in args_or_config.domains:
|
|
args_or_config.domains.append(domain)
|
|
# Each domain has a webroot_path of the most recent -w flag
|
|
# unless it was explicitly included in webroot_map
|
|
if webroot_path:
|
|
args_or_config.webroot_map.setdefault(domain, webroot_path[-1])
|
|
|
|
|
|
class WebrootMapProcessor(argparse.Action): # pylint: disable=missing-docstring
|
|
def __call__(self, parser, args, webroot_map_arg, option_string=None):
|
|
webroot_map = json.loads(webroot_map_arg)
|
|
for domains, webroot_path in webroot_map.iteritems():
|
|
_process_domain(args, domains, [webroot_path])
|
|
|
|
|
|
class DomainFlagProcessor(argparse.Action): # pylint: disable=missing-docstring
|
|
def __call__(self, parser, args, domain_arg, option_string=None):
|
|
"""Just wrap _process_domain in argparseese."""
|
|
_process_domain(args, domain_arg)
|
|
|
|
|
|
def setup_log_file_handler(config, logfile, fmt):
|
|
"""Setup file debug logging."""
|
|
log_file_path = os.path.join(config.logs_dir, logfile)
|
|
handler = logging.handlers.RotatingFileHandler(
|
|
log_file_path, maxBytes=2 ** 20, backupCount=10)
|
|
# rotate on each invocation, rollover only possible when maxBytes
|
|
# is nonzero and backupCount is nonzero, so we set maxBytes as big
|
|
# as possible not to overrun in single CLI invocation (1MB).
|
|
handler.doRollover() # TODO: creates empty letsencrypt.log.1 file
|
|
handler.setLevel(logging.DEBUG)
|
|
handler_formatter = logging.Formatter(fmt=fmt)
|
|
handler_formatter.converter = time.gmtime # don't use localtime
|
|
handler.setFormatter(handler_formatter)
|
|
return handler, log_file_path
|
|
|
|
|
|
def _cli_log_handler(config, level, fmt):
|
|
if config.text_mode or config.noninteractive_mode or config.verb == "renew":
|
|
handler = colored_logging.StreamHandler()
|
|
handler.setFormatter(logging.Formatter(fmt))
|
|
else:
|
|
handler = log.DialogHandler()
|
|
# dialog box is small, display as less as possible
|
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
handler.setLevel(level)
|
|
return handler
|
|
|
|
|
|
def setup_logging(config, cli_handler_factory, logfile):
|
|
"""Setup logging."""
|
|
fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
|
|
level = -config.verbose_count * 10
|
|
file_handler, log_file_path = setup_log_file_handler(
|
|
config, logfile=logfile, fmt=fmt)
|
|
cli_handler = cli_handler_factory(config, level, fmt)
|
|
|
|
# TODO: use fileConfig?
|
|
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(logging.DEBUG) # send all records to handlers
|
|
root_logger.addHandler(cli_handler)
|
|
root_logger.addHandler(file_handler)
|
|
|
|
logger.debug("Root logging level set at %d", level)
|
|
logger.info("Saving debug log to %s", log_file_path)
|
|
|
|
|
|
def _handle_exception(exc_type, exc_value, trace, config):
|
|
"""Logs exceptions and reports them to the user.
|
|
|
|
Config is used to determine how to display exceptions to the user. In
|
|
general, if config.debug is True, then the full exception and traceback is
|
|
shown to the user, otherwise it is suppressed. If config itself is None,
|
|
then the traceback and exception is attempted to be written to a logfile.
|
|
If this is successful, the traceback is suppressed, otherwise it is shown
|
|
to the user. sys.exit is always called with a nonzero status.
|
|
|
|
"""
|
|
logger.debug(
|
|
"Exiting abnormally:%s%s",
|
|
os.linesep,
|
|
"".join(traceback.format_exception(exc_type, exc_value, trace)))
|
|
|
|
if issubclass(exc_type, Exception) and (config is None or not config.debug):
|
|
if config is None:
|
|
logfile = "letsencrypt.log"
|
|
try:
|
|
with open(logfile, "w") as logfd:
|
|
traceback.print_exception(
|
|
exc_type, exc_value, trace, file=logfd)
|
|
except: # pylint: disable=bare-except
|
|
sys.exit("".join(
|
|
traceback.format_exception(exc_type, exc_value, trace)))
|
|
|
|
if issubclass(exc_type, errors.Error):
|
|
sys.exit(exc_value)
|
|
else:
|
|
# Here we're passing a client or ACME error out to the client at the shell
|
|
# Tell the user a bit about what happened, without overwhelming
|
|
# them with a full traceback
|
|
err = traceback.format_exception_only(exc_type, exc_value)[0]
|
|
# Typical error from the ACME module:
|
|
# acme.messages.Error: urn:acme:error:malformed :: The request message was
|
|
# malformed :: Error creating new registration :: Validation of contact
|
|
# mailto:none@longrandomstring.biz failed: Server failure at resolver
|
|
if (("urn:acme" in err and ":: " in err and
|
|
config.verbose_count <= flag_default("verbose_count"))):
|
|
# prune ACME error code, we have a human description
|
|
_code, _sep, err = err.partition(":: ")
|
|
msg = "An unexpected error occurred:\n" + err + "Please see the "
|
|
if config is None:
|
|
msg += "logfile '{0}' for more details.".format(logfile)
|
|
else:
|
|
msg += "logfiles in {0} for more details.".format(config.logs_dir)
|
|
sys.exit(msg)
|
|
else:
|
|
sys.exit("".join(
|
|
traceback.format_exception(exc_type, exc_value, trace)))
|
|
|
|
|
|
def main(cli_args=sys.argv[1:]):
|
|
"""Command line argument parsing and main script execution."""
|
|
sys.excepthook = functools.partial(_handle_exception, config=None)
|
|
plugins = plugins_disco.PluginsRegistry.find_all()
|
|
|
|
# note: arg parser internally handles --help (and exits afterwards)
|
|
args = prepare_and_parse_args(plugins, cli_args)
|
|
config = configuration.NamespaceConfig(args)
|
|
zope.component.provideUtility(config)
|
|
|
|
# Setup logging ASAP, otherwise "No handlers could be found for
|
|
# logger ..." TODO: this should be done before plugins discovery
|
|
for directory in config.config_dir, config.work_dir:
|
|
le_util.make_or_verify_dir(
|
|
directory, constants.CONFIG_DIRS_MODE, os.geteuid(),
|
|
"--strict-permissions" in cli_args)
|
|
# TODO: logs might contain sensitive data such as contents of the
|
|
# private key! #525
|
|
le_util.make_or_verify_dir(
|
|
config.logs_dir, 0o700, os.geteuid(), "--strict-permissions" in cli_args)
|
|
setup_logging(config, _cli_log_handler, logfile='letsencrypt.log')
|
|
|
|
logger.debug("letsencrypt version: %s", letsencrypt.__version__)
|
|
# do not log `config`, as it contains sensitive data (e.g. revoke --key)!
|
|
logger.debug("Arguments: %r", cli_args)
|
|
logger.debug("Discovered plugins: %r", plugins)
|
|
|
|
sys.excepthook = functools.partial(_handle_exception, config=config)
|
|
|
|
# Displayer
|
|
if config.noninteractive_mode:
|
|
displayer = display_util.NoninteractiveDisplay(sys.stdout)
|
|
elif config.text_mode:
|
|
displayer = display_util.FileDisplay(sys.stdout)
|
|
elif config.verb == "renew":
|
|
config.noninteractive_mode = True
|
|
displayer = display_util.NoninteractiveDisplay(sys.stdout)
|
|
else:
|
|
displayer = display_util.NcursesDisplay()
|
|
zope.component.provideUtility(displayer)
|
|
|
|
# Reporter
|
|
report = reporter.Reporter()
|
|
zope.component.provideUtility(report)
|
|
atexit.register(report.atexit_print_messages)
|
|
|
|
return config.func(config, plugins)
|
|
|
|
if __name__ == "__main__":
|
|
err_string = main()
|
|
if err_string:
|
|
logger.warn("Exiting with message %s", err_string)
|
|
sys.exit(err_string) # pragma: no cover
|