/ letsencrypt / cli.py
cli.py
   1  """Let's Encrypt CLI."""
   2  # TODO: Sanity check all input.  Be sure to avoid shell code etc...
   3  import argparse
   4  import atexit
   5  import functools
   6  import logging
   7  import logging.handlers
   8  import os
   9  import pkg_resources
  10  import sys
  11  import time
  12  import traceback
  13  
  14  import configargparse
  15  import OpenSSL
  16  import zope.component
  17  import zope.interface.exceptions
  18  import zope.interface.verify
  19  
  20  from acme import client as acme_client
  21  from acme import jose
  22  
  23  import letsencrypt
  24  
  25  from letsencrypt import account
  26  from letsencrypt import colored_logging
  27  from letsencrypt import configuration
  28  from letsencrypt import constants
  29  from letsencrypt import client
  30  from letsencrypt import crypto_util
  31  from letsencrypt import interfaces
  32  from letsencrypt import le_util
  33  from letsencrypt import log
  34  from letsencrypt import reporter
  35  from letsencrypt import storage
  36  
  37  from letsencrypt.display import util as display_util
  38  from letsencrypt.display import ops as display_ops
  39  from letsencrypt.errors import Error, PluginSelectionError, CertStorageError
  40  from letsencrypt.plugins import disco as plugins_disco
  41  
  42  
  43  logger = logging.getLogger(__name__)
  44  
  45  
  46  # Argparse's help formatting has a lot of unhelpful peculiarities, so we want
  47  # to replace as much of it as we can...
  48  
  49  # This is the stub to include in help generated by argparse
  50  
  51  SHORT_USAGE = """
  52    letsencrypt [SUBCOMMAND] [options] [-d domain] [-d domain] ...
  53  
  54  The Let's Encrypt agent can obtain and install HTTPS/TLS/SSL certificates.  By
  55  default, it will attempt to use a webserver both for obtaining and installing
  56  the cert.  """
  57  
  58  # This is the short help for letsencrypt --help, where we disable argparse
  59  # altogether
  60  USAGE = SHORT_USAGE + """Major SUBCOMMANDS are:
  61  
  62    (default) run        Obtain & install a cert in your current webserver
  63    auth                 Authenticate & obtain cert, but do not install it
  64    install              Install a previously obtained cert in a server
  65    revoke               Revoke a previously obtained certificate
  66    rollback             Rollback server configuration changes made during install
  67    config_changes       Show changes made to server config during installation
  68  
  69  Choice of server for authentication/installation:
  70  
  71    --apache          Use the Apache plugin for authentication & installation
  72    --nginx           Use the Nginx plugin for authentication & installation
  73    --standalone      Run a standalone webserver for authentication
  74    OR:
  75    --authenticator standalone --installer nginx
  76  
  77  More detailed help:
  78  
  79    -h, --help [topic]    print this message, or detailed help on a topic;
  80                          the available topics are:
  81  
  82     all, apache, automation, manual, nginx, paths, security, testing, or any of
  83     the subcommands
  84  """
  85  
  86  
  87  def _find_domains(args, installer):
  88      if args.domains is None:
  89          domains = display_ops.choose_names(installer)
  90      else:
  91          domains = args.domains
  92  
  93      if not domains:
  94          raise Error("Please specify --domains, or --installer that "
  95                      "will help in domain names autodiscovery")
  96  
  97      return domains
  98  
  99  
 100  def _determine_account(args, config):
 101      """Determine which account to use.
 102  
 103      In order to make the renewer (configuration de/serialization) happy,
 104      if ``args.account`` is ``None``, it will be updated based on the
 105      user input. Same for ``args.email``.
 106  
 107      :param argparse.Namespace args: CLI arguments
 108      :param letsencrypt.interface.IConfig config: Configuration object
 109      :param .AccountStorage account_storage: Account storage.
 110  
 111      :returns: Account and optionally ACME client API (biproduct of new
 112          registration).
 113      :rtype: `tuple` of `letsencrypt.account.Account` and
 114          `acme.client.Client`
 115  
 116      """
 117      account_storage = account.AccountFileStorage(config)
 118      acme = None
 119  
 120      if args.account is not None:
 121          acc = account_storage.load(args.account)
 122      else:
 123          accounts = account_storage.find_all()
 124          if len(accounts) > 1:
 125              acc = display_ops.choose_account(accounts)
 126          elif len(accounts) == 1:
 127              acc = accounts[0]
 128          else:  # no account registered yet
 129              if args.email is None:
 130                  args.email = display_ops.get_email()
 131              if not args.email:  # get_email might return ""
 132                  args.email = None
 133  
 134              def _tos_cb(regr):
 135                  if args.tos:
 136                      return True
 137                  msg = ("Please read the Terms of Service at {0}. You "
 138                         "must agree in order to register with the ACME "
 139                         "server at {1}".format(
 140                             regr.terms_of_service, config.server))
 141                  return zope.component.getUtility(interfaces.IDisplay).yesno(
 142                      msg, "Agree", "Cancel")
 143  
 144              try:
 145                  acc, acme = client.register(
 146                      config, account_storage, tos_cb=_tos_cb)
 147              except Error as error:
 148                  logger.debug(error, exc_info=True)
 149                  raise Error(
 150                      "Unable to register an account with ACME server")
 151  
 152      args.account = acc.id
 153      return acc, acme
 154  
 155  
 156  def _init_le_client(args, config, authenticator, installer):
 157      if authenticator is not None:
 158          # if authenticator was given, then we will need account...
 159          acc, acme = _determine_account(args, config)
 160          logger.debug("Picked account: %r", acc)
 161          # XXX
 162          #crypto_util.validate_key_csr(acc.key)
 163      else:
 164          acc, acme = None, None
 165  
 166      return client.Client(config, acc, authenticator, installer, acme=acme)
 167  
 168  
 169  def _find_duplicative_certs(config, domains):
 170      """Find existing certs that duplicate the request."""
 171  
 172      identical_names_cert, subset_names_cert = None, None
 173  
 174      cli_config = configuration.RenewerConfiguration(config)
 175      configs_dir = cli_config.renewal_configs_dir
 176      # Verify the directory is there
 177      le_util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid())
 178  
 179      for renewal_file in os.listdir(configs_dir):
 180          try:
 181              full_path = os.path.join(configs_dir, renewal_file)
 182              candidate_lineage = storage.RenewableCert(full_path, cli_config)
 183          except (CertStorageError, IOError):
 184              logger.warning("Renewal configuration file %s is broken. "
 185                             "Skipping.", full_path)
 186              continue
 187          # TODO: Handle these differently depending on whether they are
 188          #       expired or still valid?
 189          candidate_names = set(candidate_lineage.names())
 190          if candidate_names == set(domains):
 191              identical_names_cert = candidate_lineage
 192          elif candidate_names.issubset(set(domains)):
 193              subset_names_cert = candidate_lineage
 194  
 195      return identical_names_cert, subset_names_cert
 196  
 197  
 198  def _treat_as_renewal(config, domains):
 199      """Determine whether or not the call should be treated as a renewal.
 200  
 201      :returns: RenewableCert or None if renewal shouldn't occur.
 202      :rtype: :class:`.storage.RenewableCert`
 203  
 204      :raises .Error: If the user would like to rerun the client again.
 205  
 206      """
 207      renewal = False
 208  
 209      # Considering the possibility that the requested certificate is
 210      # related to an existing certificate.  (config.duplicate, which
 211      # is set with --duplicate, skips all of this logic and forces any
 212      # kind of certificate to be obtained with renewal = False.)
 213      if not config.duplicate:
 214          ident_names_cert, subset_names_cert = _find_duplicative_certs(
 215              config, domains)
 216          # I am not sure whether that correctly reads the systemwide
 217          # configuration file.
 218          question = None
 219          if ident_names_cert is not None:
 220              question = (
 221                  "You have an existing certificate that contains exactly the "
 222                  "same domains you requested (ref: {0}){br}{br}Do you want to "
 223                  "renew and replace this certificate with a newly-issued one?"
 224              ).format(ident_names_cert.configfile.filename, br=os.linesep)
 225          elif subset_names_cert is not None:
 226              question = (
 227                  "You have an existing certificate that contains a portion of "
 228                  "the domains you requested (ref: {0}){br}{br}It contains these "
 229                  "names: {1}{br}{br}You requested these names for the new "
 230                  "certificate: {2}.{br}{br}Do you want to replace this existing "
 231                  "certificate with the new certificate?"
 232              ).format(subset_names_cert.configfile.filename,
 233                       ", ".join(subset_names_cert.names()),
 234                       ", ".join(domains),
 235                       br=os.linesep)
 236          if question is None:
 237              # We aren't in a duplicative-names situation at all, so we don't
 238              # have to tell or ask the user anything about this.
 239              pass
 240          elif config.renew_by_default or zope.component.getUtility(
 241                  interfaces.IDisplay).yesno(question, "Replace", "Cancel"):
 242              renewal = True
 243          else:
 244              reporter_util = zope.component.getUtility(interfaces.IReporter)
 245              reporter_util.add_message(
 246                  "To obtain a new certificate that {0} an existing certificate "
 247                  "in its domain-name coverage, you must use the --duplicate "
 248                  "option.{br}{br}For example:{br}{br}{1} --duplicate {2}".format(
 249                      "duplicates" if ident_names_cert is not None else
 250                      "overlaps with",
 251                      sys.argv[0], " ".join(sys.argv[1:]),
 252                      br=os.linesep
 253                  ),
 254                  reporter_util.HIGH_PRIORITY)
 255              raise Error(
 256                  "User did not use proper CLI and would like "
 257                  "to reinvoke the client.")
 258  
 259          if renewal:
 260              return ident_names_cert if ident_names_cert is not None else subset_names_cert
 261  
 262      return None
 263  
 264  
 265  def _report_new_cert(cert_path, fullchain_path):
 266      """Reports the creation of a new certificate to the user.
 267  
 268      :param str cert_path: path to cert
 269      :param str fullchain_path: path to full chain
 270  
 271      """
 272      expiry = crypto_util.notAfter(cert_path).date()
 273      reporter_util = zope.component.getUtility(interfaces.IReporter)
 274      if fullchain_path:
 275          # Print the path to fullchain.pem because that's what modern webservers
 276          # (Nginx and Apache2.4) will want.
 277          and_chain = "and chain have"
 278          path = fullchain_path
 279      else:
 280          # Unless we're in .csr mode and there really isn't one
 281          and_chain = "has "
 282          path = cert_path
 283      # XXX Perhaps one day we could detect the presence of known old webservers
 284      # and say something more informative here.
 285      msg = ("Congratulations! Your certificate {0} been saved at {1}."
 286             " Your cert will expire on {2}. To obtain a new version of the "
 287             "certificate in the future, simply run Let's Encrypt again."
 288             .format(and_chain, path, expiry))
 289      reporter_util.add_message(msg, reporter_util.MEDIUM_PRIORITY)
 290  
 291  
 292  def _auth_from_domains(le_client, config, domains, plugins):
 293      """Authenticate and enroll certificate."""
 294      # Note: This can raise errors... caught above us though.
 295      lineage = _treat_as_renewal(config, domains)
 296  
 297      if lineage is not None:
 298          # TODO: schoen wishes to reuse key - discussion
 299          # https://github.com/letsencrypt/letsencrypt/pull/777/files#r40498574
 300          new_certr, new_chain, new_key, _ = le_client.obtain_certificate(domains)
 301          # TODO: Check whether it worked! <- or make sure errors are thrown (jdk)
 302          lineage.save_successor(
 303              lineage.latest_common_version(), OpenSSL.crypto.dump_certificate(
 304                  OpenSSL.crypto.FILETYPE_PEM, new_certr.body),
 305              new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain))
 306  
 307          lineage.update_all_links_to(lineage.latest_common_version())
 308          # TODO: Check return value of save_successor
 309          # TODO: Also update lineage renewal config with any relevant
 310          #       configuration values from this attempt? <- Absolutely (jdkasten)
 311      else:
 312          # TREAT AS NEW REQUEST
 313          lineage = le_client.obtain_and_enroll_certificate(domains, plugins)
 314          if not lineage:
 315              raise Error("Certificate could not be obtained")
 316  
 317      _report_new_cert(lineage.cert, lineage.fullchain)
 318  
 319      return lineage
 320  
 321  
 322  def set_configurator(previously, now):
 323      """
 324      Setting configurators multiple ways is okay, as long as they all agree
 325      :param str previously: previously identified request for the installer/authenticator
 326      :param str requested: the request currently being processed
 327      """
 328      if now is None:
 329          # we're not actually setting anything
 330          return previously
 331      if previously:
 332          if previously != now:
 333              msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}"
 334              raise PluginSelectionError(msg.format(repr(previously), repr(now)))
 335      return now
 336  
 337  
 338  def diagnose_configurator_problem(cfg_type, requested, plugins):
 339      """
 340      Raise the most helpful error message about a plugin being unavailable
 341  
 342      :param str cfg_type: either "installer" or "authenticator"
 343      :param str requested: the plugin that was requested
 344      :param PluginRegistry plugins: available plugins
 345  
 346      :raises error.PluginSelectionError: if there was a problem
 347      """
 348  
 349      if requested:
 350          if requested not in plugins:
 351              msg = "The requested {0} plugin does not appear to be installed".format(requested)
 352          else:
 353              msg = ("The {0} plugin is not working; there may be problems with "
 354                     "your existing configuration.\nThe error was: {1!r}"
 355                     .format(requested, plugins[requested].problem))
 356      elif cfg_type == "installer":
 357          if os.path.exists("/etc/debian_version"):
 358              # Debian... installers are at least possible
 359              msg = ('No installers seem to be present and working on your system; '
 360                     'fix that or try running letsencrypt with the "auth" command')
 361          else:
 362              # XXX update this logic as we make progress on #788 and nginx support
 363              msg = ('No installers are available on your OS yet; try running '
 364                     '"letsencrypt-auto auth" to get a cert you can install manually')
 365      else:
 366          msg = "{0} could not be determined or is not installed".format(cfg_type)
 367      raise PluginSelectionError(msg)
 368  
 369  
 370  def choose_configurator_plugins(args, config, plugins, verb):
 371      """
 372      Figure out which configurator we're going to use
 373  
 374      :raises error.PluginSelectionError if there was a problem
 375      """
 376  
 377      # Which plugins do we need?
 378      need_inst = need_auth = (verb == "run")
 379      if verb == "auth":
 380          need_auth = True
 381      if verb == "install":
 382          need_inst = True
 383          if args.authenticator:
 384              logger.warn("Specifying an authenticator doesn't make sense in install mode")
 385  
 386      # Which plugins did the user request?
 387      req_inst = req_auth = args.configurator
 388      req_inst = set_configurator(req_inst, args.installer)
 389      req_auth = set_configurator(req_auth, args.authenticator)
 390      if args.nginx:
 391          req_inst = set_configurator(req_inst, "nginx")
 392          req_auth = set_configurator(req_auth, "nginx")
 393      if args.apache:
 394          req_inst = set_configurator(req_inst, "apache")
 395          req_auth = set_configurator(req_auth, "apache")
 396      if args.standalone:
 397          req_auth = set_configurator(req_auth, "standalone")
 398      logger.debug("Requested authenticator %s and installer %s", req_auth, req_inst)
 399  
 400      # Try to meet the user's request and/or ask them to pick plugins
 401      authenticator = installer = None
 402      if verb == "run" and req_auth == req_inst:
 403          # Unless the user has explicitly asked for different auth/install,
 404          # only consider offering a single choice
 405          authenticator = installer = display_ops.pick_configurator(config, req_inst, plugins)
 406      else:
 407          if need_inst or req_inst:
 408              installer = display_ops.pick_installer(config, req_inst, plugins)
 409          if need_auth:
 410              authenticator = display_ops.pick_authenticator(config, req_auth, plugins)
 411      logger.debug("Selected authenticator %s and installer %s", authenticator, installer)
 412  
 413      if need_inst and not installer:
 414          diagnose_configurator_problem("installer", req_inst, plugins)
 415      if need_auth and not authenticator:
 416          diagnose_configurator_problem("authenticator", req_auth, plugins)
 417  
 418      return installer, authenticator
 419  
 420  
 421  # TODO: Make run as close to auth + install as possible
 422  # Possible difficulties: args.csr was hacked into auth
 423  def run(args, config, plugins):  # pylint: disable=too-many-branches,too-many-locals
 424      """Obtain a certificate and install."""
 425      try:
 426          installer, authenticator = choose_configurator_plugins(args, config, plugins, "run")
 427      except PluginSelectionError, e:
 428          return e.message
 429  
 430      domains = _find_domains(args, installer)
 431  
 432      # TODO: Handle errors from _init_le_client?
 433      le_client = _init_le_client(args, config, authenticator, installer)
 434  
 435      lineage = _auth_from_domains(le_client, config, domains, plugins)
 436  
 437      le_client.deploy_certificate(
 438          domains, lineage.privkey, lineage.cert,
 439          lineage.chain, lineage.fullchain)
 440      le_client.enhance_config(domains, args.redirect)
 441  
 442      if len(lineage.available_versions("cert")) == 1:
 443          display_ops.success_installation(domains)
 444      else:
 445          display_ops.success_renewal(domains)
 446  
 447  
 448  def auth(args, config, plugins):
 449      """Authenticate & obtain cert, but do not install it."""
 450  
 451      if args.domains is not None and args.csr is not None:
 452          # TODO: --csr could have a priority, when --domains is
 453          # supplied, check if CSR matches given domains?
 454          return "--domains and --csr are mutually exclusive"
 455  
 456      try:
 457          # installers are used in auth mode to determine domain names
 458          installer, authenticator = choose_configurator_plugins(args, config, plugins, "auth")
 459      except PluginSelectionError, e:
 460          return e.message
 461  
 462      # TODO: Handle errors from _init_le_client?
 463      le_client = _init_le_client(args, config, authenticator, installer)
 464  
 465      # This is a special case; cert and chain are simply saved
 466      if args.csr is not None:
 467          certr, chain = le_client.obtain_certificate_from_csr(le_util.CSR(
 468              file=args.csr[0], data=args.csr[1], form="der"))
 469          cert_path, _, cert_fullchain = le_client.save_certificate(
 470              certr, chain, args.cert_path, args.chain_path, args.fullchain_path)
 471          _report_new_cert(cert_path, cert_fullchain)
 472      else:
 473          domains = _find_domains(args, installer)
 474          _auth_from_domains(le_client, config, domains, plugins)
 475  
 476  
 477  def install(args, config, plugins):
 478      """Install a previously obtained cert in a server."""
 479      # XXX: Update for renewer/RenewableCert
 480  
 481      try:
 482          installer, _ = choose_configurator_plugins(args, config, plugins, "auth")
 483      except PluginSelectionError, e:
 484          return e.message
 485  
 486      domains = _find_domains(args, installer)
 487      le_client = _init_le_client(
 488          args, config, authenticator=None, installer=installer)
 489      assert args.cert_path is not None  # required=True in the subparser
 490      le_client.deploy_certificate(
 491          domains, args.key_path, args.cert_path, args.chain_path,
 492          args.fullchain_path)
 493      le_client.enhance_config(domains, args.redirect)
 494  
 495  
 496  def revoke(args, config, unused_plugins):  # TODO: coop with renewal config
 497      """Revoke a previously obtained certificate."""
 498      if args.key_path is not None:  # revocation by cert key
 499          logger.debug("Revoking %s using cert key %s",
 500                       args.cert_path[0], args.key_path[0])
 501          acme = acme_client.Client(
 502              config.server, key=jose.JWK.load(args.key_path[1]))
 503      else:  # revocation by account key
 504          logger.debug("Revoking %s using Account Key", args.cert_path[0])
 505          acc, _ = _determine_account(args, config)
 506          # pylint: disable=protected-access
 507          acme = client._acme_from_config_key(config, acc.key)
 508      acme.revoke(jose.ComparableX509(crypto_util.pyopenssl_load_certificate(
 509          args.cert_path[1])[0]))
 510  
 511  
 512  def rollback(args, config, plugins):
 513      """Rollback server configuration changes made during install."""
 514      client.rollback(args.installer, args.checkpoints, config, plugins)
 515  
 516  
 517  def config_changes(unused_args, config, unused_plugins):
 518      """Show changes made to server config during installation
 519  
 520      View checkpoints and associated configuration changes.
 521  
 522      """
 523      client.view_config_changes(config)
 524  
 525  
 526  def plugins_cmd(args, config, plugins):  # TODO: Use IDisplay rather than print
 527      """List server software plugins."""
 528      logger.debug("Expected interfaces: %s", args.ifaces)
 529  
 530      ifaces = [] if args.ifaces is None else args.ifaces
 531      filtered = plugins.visible().ifaces(ifaces)
 532      logger.debug("Filtered plugins: %r", filtered)
 533  
 534      if not args.init and not args.prepare:
 535          print str(filtered)
 536          return
 537  
 538      filtered.init(config)
 539      verified = filtered.verify(ifaces)
 540      logger.debug("Verified plugins: %r", verified)
 541  
 542      if not args.prepare:
 543          print str(verified)
 544          return
 545  
 546      verified.prepare()
 547      available = verified.available()
 548      logger.debug("Prepared plugins: %s", available)
 549      print str(available)
 550  
 551  
 552  def read_file(filename, mode="rb"):
 553      """Returns the given file's contents.
 554  
 555      :param str filename: Filename
 556      :param str mode: open mode (see `open`)
 557  
 558      :returns: A tuple of filename and its contents
 559      :rtype: tuple
 560  
 561      :raises argparse.ArgumentTypeError: File does not exist or is not readable.
 562  
 563      """
 564      try:
 565          return filename, open(filename, mode).read()
 566      except IOError as exc:
 567          raise argparse.ArgumentTypeError(exc.strerror)
 568  
 569  
 570  def flag_default(name):
 571      """Default value for CLI flag."""
 572      return constants.CLI_DEFAULTS[name]
 573  
 574  
 575  def config_help(name, hidden=False):
 576      """Help message for `.IConfig` attribute."""
 577      if hidden:
 578          return argparse.SUPPRESS
 579      else:
 580          return interfaces.IConfig[name].__doc__
 581  
 582  
 583  class SilentParser(object):  # pylint: disable=too-few-public-methods
 584      """Silent wrapper around argparse.
 585  
 586      A mini parser wrapper that doesn't print help for its
 587      arguments. This is needed for the use of callbacks to define
 588      arguments within plugins.
 589  
 590      """
 591      def __init__(self, parser):
 592          self.parser = parser
 593  
 594      def add_argument(self, *args, **kwargs):
 595          """Wrap, but silence help"""
 596          kwargs["help"] = argparse.SUPPRESS
 597          self.parser.add_argument(*args, **kwargs)
 598  
 599  
 600  class HelpfulArgumentParser(object):
 601      """Argparse Wrapper.
 602  
 603      This class wraps argparse, adding the ability to make --help less
 604      verbose, and request help on specific subcategories at a time, eg
 605      'letsencrypt --help security' for security options.
 606  
 607      """
 608      def __init__(self, args, plugins):
 609          plugin_names = [name for name, _p in plugins.iteritems()]
 610          self.help_topics = HELP_TOPICS + plugin_names + [None]
 611          self.parser = configargparse.ArgParser(
 612              usage=SHORT_USAGE,
 613              formatter_class=argparse.ArgumentDefaultsHelpFormatter,
 614              args_for_setting_config_path=["-c", "--config"],
 615              default_config_files=flag_default("config_files"))
 616  
 617          # This is the only way to turn off overly verbose config flag documentation
 618          self.parser._add_config_file_help = False  # pylint: disable=protected-access
 619          self.silent_parser = SilentParser(self.parser)
 620  
 621          self.verb = None
 622          self.args = self.preprocess_args(args)
 623          help1 = self.prescan_for_flag("-h", self.help_topics)
 624          help2 = self.prescan_for_flag("--help", self.help_topics)
 625          assert max(True, "a") == "a", "Gravity changed direction"
 626          help_arg = max(help1, help2)
 627          if help_arg is True:
 628              # just --help with no topic; avoid argparse altogether
 629              print USAGE
 630              sys.exit(0)
 631          self.visible_topics = self.determine_help_topics(help_arg)
 632          #print self.visible_topics
 633          self.groups = {}  # elements are added by .add_group()
 634  
 635      def preprocess_args(self, args):
 636          """Work around some limitations in argparse.
 637  
 638          Currently: add the default verb "run" as a default, and ensure that the
 639          subcommand / verb comes last.
 640          """
 641          if "-h" in args or "--help" in args:
 642              # all verbs double as help arguments; don't get them confused
 643              self.verb = "help"
 644              return args
 645  
 646          for i, token in enumerate(args):
 647              if token in VERBS:
 648                  reordered = args[:i] + args[(i + 1):] + [args[i]]
 649                  self.verb = token
 650                  return reordered
 651  
 652          self.verb = "run"
 653          return args + ["run"]
 654  
 655      def prescan_for_flag(self, flag, possible_arguments):
 656          """Checks cli input for flags.
 657  
 658          Check for a flag, which accepts a fixed set of possible arguments, in
 659          the command line; we will use this information to configure argparse's
 660          help correctly.  Return the flag's argument, if it has one that matches
 661          the sequence @possible_arguments; otherwise return whether the flag is
 662          present.
 663  
 664          """
 665          if flag not in self.args:
 666              return False
 667          pos = self.args.index(flag)
 668          try:
 669              nxt = self.args[pos + 1]
 670              if nxt in possible_arguments:
 671                  return nxt
 672          except IndexError:
 673              pass
 674          return True
 675  
 676      def add(self, topic, *args, **kwargs):
 677          """Add a new command line argument.
 678  
 679          @topic is required, to indicate which part of the help will document
 680          it, but can be None for `always documented'.
 681  
 682          """
 683          if self.visible_topics[topic]:
 684              if topic in self.groups:
 685                  group = self.groups[topic]
 686                  group.add_argument(*args, **kwargs)
 687              else:
 688                  self.parser.add_argument(*args, **kwargs)
 689          else:
 690              kwargs["help"] = argparse.SUPPRESS
 691              self.parser.add_argument(*args, **kwargs)
 692  
 693      def add_group(self, topic, **kwargs):
 694          """
 695  
 696          This has to be called once for every topic; but we leave those calls
 697          next to the argument definitions for clarity. Return something
 698          arguments can be added to if necessary, either the parser or an argument
 699          group.
 700  
 701          """
 702          if self.visible_topics[topic]:
 703              #print "Adding visible group " + topic
 704              group = self.parser.add_argument_group(topic, **kwargs)
 705              self.groups[topic] = group
 706              return group
 707          else:
 708              #print "Invisible group " + topic
 709              return self.silent_parser
 710  
 711      def add_plugin_args(self, plugins):
 712          """
 713  
 714          Let each of the plugins add its own command line arguments, which
 715          may or may not be displayed as help topics.
 716  
 717          """
 718          for name, plugin_ep in plugins.iteritems():
 719              parser_or_group = self.add_group(name, description=plugin_ep.description)
 720              #print parser_or_group
 721              plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name)
 722  
 723      def determine_help_topics(self, chosen_topic):
 724          """
 725  
 726          The user may have requested help on a topic, return a dict of which
 727          topics to display. @chosen_topic has prescan_for_flag's return type
 728  
 729          :returns: dict
 730  
 731          """
 732          # topics maps each topic to whether it should be documented by
 733          # argparse on the command line
 734          if chosen_topic == "all":
 735              return dict([(t, True) for t in self.help_topics])
 736          elif not chosen_topic:
 737              return dict([(t, False) for t in self.help_topics])
 738          else:
 739              return dict([(t, t == chosen_topic) for t in self.help_topics])
 740  
 741  
 742  def create_parser(plugins, args):
 743      """Create parser."""
 744      helpful = HelpfulArgumentParser(args, plugins)
 745  
 746      # --help is automatically provided by argparse
 747      helpful.add(
 748          None, "-v", "--verbose", dest="verbose_count", action="count",
 749          default=flag_default("verbose_count"), help="This flag can be used "
 750          "multiple times to incrementally increase the verbosity of output, "
 751          "e.g. -vvv.")
 752      helpful.add(
 753          None, "-t", "--text", dest="text_mode", action="store_true",
 754          help="Use the text output instead of the curses UI.")
 755      helpful.add(None, "-m", "--email", help=config_help("email"))
 756      # positional arg shadows --domains, instead of appending, and
 757      # --domains is useful, because it can be stored in config
 758      #for subparser in parser_run, parser_auth, parser_install:
 759      #    subparser.add_argument("domains", nargs="*", metavar="domain")
 760      helpful.add(None, "-d", "--domains", metavar="DOMAIN", action="append")
 761      helpful.add(
 762          None, "--duplicate", dest="duplicate", action="store_true",
 763          help="Allow getting a certificate that duplicates an existing one")
 764  
 765      helpful.add_group(
 766          "automation",
 767          description="Arguments for automating execution & other tweaks")
 768      helpful.add(
 769          "automation", "--version", action="version",
 770          version="%(prog)s {0}".format(letsencrypt.__version__),
 771          help="show program's version number and exit")
 772      helpful.add(
 773          "automation", "--renew-by-default", action="store_true",
 774          help="Select renewal by default when domains are a superset of a "
 775               "a previously attained cert")
 776      helpful.add(
 777          "automation", "--agree-dev-preview", action="store_true",
 778          help="Agree to the Let's Encrypt Developer Preview Disclaimer")
 779      helpful.add(
 780          "automation", "--agree-tos", dest="tos", action="store_true",
 781          help="Agree to the Let's Encrypt Subscriber Agreement")
 782      helpful.add(
 783          "automation", "--account", metavar="ACCOUNT_ID",
 784          help="Account ID to use")
 785  
 786      helpful.add_group(
 787          "testing", description="The following flags are meant for "
 788          "testing purposes only! Do NOT change them, unless you "
 789          "really know what you're doing!")
 790      helpful.add(
 791          "testing", "--debug", action="store_true",
 792          help="Show tracebacks if the program exits abnormally")
 793      helpful.add(
 794          "testing", "--no-verify-ssl", action="store_true",
 795          help=config_help("no_verify_ssl"),
 796          default=flag_default("no_verify_ssl"))
 797      helpful.add(  # TODO: apache plugin does NOT respect it (#479)
 798          "testing", "--dvsni-port", type=int, default=flag_default("dvsni_port"),
 799          help=config_help("dvsni_port"))
 800      helpful.add("testing", "--simple-http-port", type=int,
 801                  help=config_help("simple_http_port"))
 802  
 803      helpful.add_group(
 804          "security", description="Security parameters & server settings")
 805      helpful.add(
 806          "security", "-B", "--rsa-key-size", type=int, metavar="N",
 807          default=flag_default("rsa_key_size"), help=config_help("rsa_key_size"))
 808      # TODO: resolve - assumes binary logic while client.py assumes ternary.
 809      helpful.add(
 810          "security", "-r", "--redirect", action="store_true",
 811          help="Automatically redirect all HTTP traffic to HTTPS for the newly "
 812               "authenticated vhost.")
 813      helpful.add(
 814          "security", "--strict-permissions", action="store_true",
 815          help="Require that all configuration files are owned by the current "
 816               "user; only needed if your config is somewhere unsafe like /tmp/")
 817  
 818      _paths_parser(helpful)
 819      # _plugins_parsing should be the last thing to act upon the main
 820      # parser (--help should display plugin-specific options last)
 821      _plugins_parsing(helpful, plugins)
 822  
 823      _create_subparsers(helpful)
 824  
 825      return helpful.parser, helpful.args
 826  
 827  
 828  # For now unfortunately this constant just needs to match the code below;
 829  # there isn't an elegant way to autogenerate it in time.
 830  VERBS = ["run", "auth", "install", "revoke", "rollback", "config_changes", "plugins"]
 831  HELP_TOPICS = ["all", "security", "paths", "automation", "testing"] + VERBS
 832  
 833  
 834  def _create_subparsers(helpful):
 835      subparsers = helpful.parser.add_subparsers(metavar="SUBCOMMAND")
 836  
 837      def add_subparser(name):  # pylint: disable=missing-docstring
 838          if name == "plugins":
 839              func = plugins_cmd
 840          else:
 841              func = eval(name)  # pylint: disable=eval-used
 842          h = func.__doc__.splitlines()[0]
 843          subparser = subparsers.add_parser(name, help=h, description=func.__doc__)
 844          subparser.set_defaults(func=func)
 845          return subparser
 846  
 847      # the order of add_subparser() calls is important: it defines the
 848      # order in which subparser names will be displayed in --help
 849      # these add_subparser objects return objects to which arguments could be
 850      # attached, but they have annoying arg ordering constrains so we use
 851      # groups instead: https://github.com/letsencrypt/letsencrypt/issues/820
 852      for v in VERBS:
 853          add_subparser(v)
 854  
 855      helpful.add_group("auth", description="Options for modifying how a cert is obtained")
 856      helpful.add_group("install", description="Options for modifying how a cert is deployed")
 857      helpful.add_group("revoke", description="Options for revocation of certs")
 858      helpful.add_group("rollback", description="Options for reverting config changes")
 859      helpful.add_group("plugins", description="Plugin options")
 860  
 861      helpful.add("auth",
 862                  "--csr", type=read_file,
 863                  help="Path to a Certificate Signing Request (CSR) in DER format.")
 864      helpful.add("rollback",
 865                  "--checkpoints", type=int, metavar="N",
 866                  default=flag_default("rollback_checkpoints"),
 867                  help="Revert configuration N number of checkpoints.")
 868      helpful.add("plugins",
 869                  "--init", action="store_true", help="Initialize plugins.")
 870      helpful.add("plugins",
 871                  "--prepare", action="store_true", help="Initialize and prepare plugins.")
 872      helpful.add("plugins",
 873                  "--authenticators", action="append_const", dest="ifaces",
 874                  const=interfaces.IAuthenticator, help="Limit to authenticator plugins only.")
 875      helpful.add("plugins",
 876                  "--installers", action="append_const", dest="ifaces",
 877                  const=interfaces.IInstaller, help="Limit to installer plugins only.")
 878  
 879  
 880  def _paths_parser(helpful):
 881      add = helpful.add
 882      verb = helpful.verb
 883      helpful.add_group(
 884          "paths", description="Arguments changing execution paths & servers")
 885  
 886      cph = "Path to where cert is saved (with auth), installed (with install --csr) or revoked."
 887      if verb == "auth":
 888          add("paths", "--cert-path", default=flag_default("auth_cert_path"), help=cph)
 889      elif verb == "revoke":
 890          add("paths", "--cert-path", type=read_file, required=True, help=cph)
 891      else:
 892          add("paths", "--cert-path", help=cph, required=(verb == "install"))
 893  
 894      # revoke --key-path reads a file, install --key-path takes a string
 895      add("paths", "--key-path", type=((verb == "revoke" and read_file) or str),
 896          required=(verb == "install"),
 897          help="Path to private key for cert creation or revocation (if account key is missing)")
 898  
 899      default_cp = None
 900      if verb == "auth":
 901          default_cp = flag_default("auth_chain_path")
 902      add("paths", "--fullchain-path", default=default_cp,
 903          help="Accompanying path to a full certificate chain (cert plus chain).")
 904      add("paths", "--chain-path", default=default_cp,
 905          help="Accompanying path to a certificate chain.")
 906      add("paths", "--config-dir", default=flag_default("config_dir"),
 907          help=config_help("config_dir"))
 908      add("paths", "--work-dir", default=flag_default("work_dir"),
 909          help=config_help("work_dir"))
 910      add("paths", "--logs-dir", default=flag_default("logs_dir"),
 911          help="Logs directory.")
 912      add("paths", "--server", default=flag_default("server"),
 913          help=config_help("server"))
 914  
 915  
 916  def _plugins_parsing(helpful, plugins):
 917      helpful.add_group(
 918          "plugins", description="Let's Encrypt client supports an "
 919          "extensible plugins architecture. See '%(prog)s plugins' for a "
 920          "list of all available plugins and their names. You can force "
 921          "a particular plugin by setting options provided below. Further "
 922          "down this help message you will find plugin-specific options "
 923          "(prefixed by --{plugin_name}).")
 924      helpful.add(
 925          "plugins", "-a", "--authenticator", help="Authenticator plugin name.")
 926      helpful.add(
 927          "plugins", "-i", "--installer", help="Installer plugin name (also used to find domains).")
 928      helpful.add(
 929          "plugins", "--configurator", help="Name of the plugin that is "
 930          "both an authenticator and an installer. Should not be used "
 931          "together with --authenticator or --installer.")
 932      helpful.add("plugins", "--apache", action="store_true",
 933                  help="Obtain and install certs using Apache")
 934      helpful.add("plugins", "--nginx", action="store_true",
 935                  help="Obtain and install certs using Nginx")
 936      helpful.add("plugins", "--standalone", action="store_true",
 937                  help='Obtain certs using a "standalone" webserver.')
 938  
 939      # things should not be reorder past/pre this comment:
 940      # plugins_group should be displayed in --help before plugin
 941      # specific groups (so that plugins_group.description makes sense)
 942  
 943      helpful.add_plugin_args(plugins)
 944  
 945  
 946  def setup_log_file_handler(args, logfile, fmt):
 947      """Setup file debug logging."""
 948      log_file_path = os.path.join(args.logs_dir, logfile)
 949      handler = logging.handlers.RotatingFileHandler(
 950          log_file_path, maxBytes=2 ** 20, backupCount=10)
 951      # rotate on each invocation, rollover only possible when maxBytes
 952      # is nonzero and backupCount is nonzero, so we set maxBytes as big
 953      # as possible not to overrun in single CLI invocation (1MB).
 954      handler.doRollover()  # TODO: creates empty letsencrypt.log.1 file
 955      handler.setLevel(logging.DEBUG)
 956      handler_formatter = logging.Formatter(fmt=fmt)
 957      handler_formatter.converter = time.gmtime  # don't use localtime
 958      handler.setFormatter(handler_formatter)
 959      return handler, log_file_path
 960  
 961  
 962  def _cli_log_handler(args, level, fmt):
 963      if args.text_mode:
 964          handler = colored_logging.StreamHandler()
 965          handler.setFormatter(logging.Formatter(fmt))
 966      else:
 967          handler = log.DialogHandler()
 968          # dialog box is small, display as less as possible
 969          handler.setFormatter(logging.Formatter("%(message)s"))
 970      handler.setLevel(level)
 971      return handler
 972  
 973  
 974  def setup_logging(args, cli_handler_factory, logfile):
 975      """Setup logging."""
 976      fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
 977      level = -args.verbose_count * 10
 978      file_handler, log_file_path = setup_log_file_handler(
 979          args, logfile=logfile, fmt=fmt)
 980      cli_handler = cli_handler_factory(args, level, fmt)
 981  
 982      # TODO: use fileConfig?
 983  
 984      root_logger = logging.getLogger()
 985      root_logger.setLevel(logging.DEBUG)  # send all records to handlers
 986      root_logger.addHandler(cli_handler)
 987      root_logger.addHandler(file_handler)
 988  
 989      logger.debug("Root logging level set at %d", level)
 990      logger.info("Saving debug log to %s", log_file_path)
 991  
 992  
 993  def _handle_exception(exc_type, exc_value, trace, args):
 994      """Logs exceptions and reports them to the user.
 995  
 996      Args is used to determine how to display exceptions to the user. In
 997      general, if args.debug is True, then the full exception and traceback is
 998      shown to the user, otherwise it is suppressed. If args itself is None,
 999      then the traceback and exception is attempted to be written to a logfile.
1000      If this is successful, the traceback is suppressed, otherwise it is shown
1001      to the user. sys.exit is always called with a nonzero status.
1002  
1003      """
1004      logger.debug(
1005          "Exiting abnormally:%s%s",
1006          os.linesep,
1007          "".join(traceback.format_exception(exc_type, exc_value, trace)))
1008  
1009      if issubclass(exc_type, Exception) and (args is None or not args.debug):
1010          if args is None:
1011              logfile = "letsencrypt.log"
1012              try:
1013                  with open(logfile, "w") as logfd:
1014                      traceback.print_exception(
1015                          exc_type, exc_value, trace, file=logfd)
1016              except:  # pylint: disable=bare-except
1017                  sys.exit("".join(
1018                      traceback.format_exception(exc_type, exc_value, trace)))
1019  
1020          if issubclass(exc_type, Error):
1021              sys.exit(exc_value)
1022          else:
1023              # Tell the user a bit about what happened, without overwhelming
1024              # them with a full traceback
1025              msg = ("An unexpected error occurred.\n" +
1026                     traceback.format_exception_only(exc_type, exc_value)[0] +
1027                     "Please see the ")
1028              if args is None:
1029                  msg += "logfile '{0}' for more details.".format(logfile)
1030              else:
1031                  msg += "logfiles in {0} for more details.".format(args.logs_dir)
1032              sys.exit(msg)
1033      else:
1034          sys.exit("".join(
1035              traceback.format_exception(exc_type, exc_value, trace)))
1036  
1037  
1038  def main(cli_args=sys.argv[1:]):
1039      """Command line argument parsing and main script execution."""
1040      sys.excepthook = functools.partial(_handle_exception, args=None)
1041  
1042      # note: arg parser internally handles --help (and exits afterwards)
1043      plugins = plugins_disco.PluginsRegistry.find_all()
1044      parser, tweaked_cli_args = create_parser(plugins, cli_args)
1045      args = parser.parse_args(tweaked_cli_args)
1046      config = configuration.NamespaceConfig(args)
1047      zope.component.provideUtility(config)
1048  
1049      # Setup logging ASAP, otherwise "No handlers could be found for
1050      # logger ..." TODO: this should be done before plugins discovery
1051      for directory in config.config_dir, config.work_dir:
1052          le_util.make_or_verify_dir(
1053              directory, constants.CONFIG_DIRS_MODE, os.geteuid(),
1054              "--strict-permissions" in cli_args)
1055      # TODO: logs might contain sensitive data such as contents of the
1056      # private key! #525
1057      le_util.make_or_verify_dir(
1058          args.logs_dir, 0o700, os.geteuid(), "--strict-permissions" in cli_args)
1059      setup_logging(args, _cli_log_handler, logfile='letsencrypt.log')
1060  
1061      logger.debug("letsencrypt version: %s", letsencrypt.__version__)
1062      # do not log `args`, as it contains sensitive data (e.g. revoke --key)!
1063      logger.debug("Arguments: %r", cli_args)
1064      logger.debug("Discovered plugins: %r", plugins)
1065  
1066      sys.excepthook = functools.partial(_handle_exception, args=args)
1067  
1068      # Displayer
1069      if args.text_mode:
1070          displayer = display_util.FileDisplay(sys.stdout)
1071      else:
1072          displayer = display_util.NcursesDisplay()
1073      zope.component.provideUtility(displayer)
1074  
1075      # Reporter
1076      report = reporter.Reporter()
1077      zope.component.provideUtility(report)
1078      atexit.register(report.atexit_print_messages)
1079  
1080      # TODO: remove developer preview prompt for the launch
1081      if not config.agree_dev_preview:
1082          disclaimer = pkg_resources.resource_string("letsencrypt", "DISCLAIMER")
1083          if not zope.component.getUtility(interfaces.IDisplay).yesno(
1084                  disclaimer, "Agree", "Cancel"):
1085              raise Error("Must agree to TOS")
1086  
1087      if not os.geteuid() == 0:
1088          logger.warning(
1089              "Root (sudo) is required to run most of letsencrypt functionality.")
1090          # check must be done after arg parsing as --help should work
1091          # w/o root; on the other hand, e.g. "letsencrypt run
1092          # --authenticator dns" or "letsencrypt plugins" does not
1093          # require root as well
1094          #return (
1095          #    "{0}Root is required to run letsencrypt.  Please use sudo.{0}"
1096          #    .format(os.linesep))
1097  
1098      return args.func(args, config, plugins)
1099  
1100  
1101  if __name__ == "__main__":
1102      err_string = main()
1103      if err_string:
1104          logger.warn("Exiting with message %s", err_string)
1105      sys.exit(err_string)  # pragma: no cover