/ 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