1"""Certbot command line argument parser""" 2 3import argparse 4import copy 5import functools 6import glob 7import sys 8from typing import Any 9from typing import Dict 10from typing import Iterable 11from typing import List 12from typing import Optional 13from typing import Union 14 15import configargparse 16 17from certbot import crypto_util 18from certbot import errors 19from certbot import util 20from certbot._internal import constants 21from certbot._internal import hooks 22from certbot._internal.cli.cli_constants import ARGPARSE_PARAMS_TO_REMOVE 23from certbot._internal.cli.cli_constants import COMMAND_OVERVIEW 24from certbot._internal.cli.cli_constants import EXIT_ACTIONS 25from certbot._internal.cli.cli_constants import HELP_AND_VERSION_USAGE 26from certbot._internal.cli.cli_constants import SHORT_USAGE 27from certbot._internal.cli.cli_constants import ZERO_ARG_ACTIONS 28from certbot._internal.cli.cli_utils import _Default 29from certbot._internal.cli.cli_utils import add_domains 30from certbot._internal.cli.cli_utils import CustomHelpFormatter 31from certbot._internal.cli.cli_utils import flag_default 32from certbot._internal.cli.cli_utils import HelpfulArgumentGroup 33from certbot._internal.cli.verb_help import VERB_HELP 34from certbot._internal.cli.verb_help import VERB_HELP_MAP 35from certbot._internal.display import obj as display_obj 36from certbot._internal.plugins import disco 37from certbot.compat import os 38 39 40class HelpfulArgumentParser: 41 """Argparse Wrapper. 42 43 This class wraps argparse, adding the ability to make --help less 44 verbose, and request help on specific subcategories at a time, eg 45 'certbot --help security' for security options. 46 47 """ 48 def __init__(self, args: List[str], plugins: Iterable[str], 49 detect_defaults: bool = False) -> None: 50 from certbot._internal import main 51 self.VERBS = { 52 "auth": main.certonly, 53 "certonly": main.certonly, 54 "run": main.run, 55 "install": main.install, 56 "plugins": main.plugins_cmd, 57 "register": main.register, 58 "update_account": main.update_account, 59 "unregister": main.unregister, 60 "renew": main.renew, 61 "revoke": main.revoke, 62 "rollback": main.rollback, 63 "everything": main.run, 64 "update_symlinks": main.update_symlinks, 65 "certificates": main.certificates, 66 "delete": main.delete, 67 "enhance": main.enhance, 68 } 69 70 # Get notification function for printing 71 self.notify = display_obj.NoninteractiveDisplay(sys.stdout).notification 72 73 # List of topics for which additional help can be provided 74 HELP_TOPICS = ["all", "security", "paths", "automation", "testing"] 75 HELP_TOPICS += list(self.VERBS) + self.COMMANDS_TOPICS + ["manage"] 76 77 plugin_names = list(plugins) 78 self.help_topics = HELP_TOPICS + plugin_names + [None] # type: ignore 79 80 self.detect_defaults = detect_defaults 81 self.args = args 82 83 if self.args and self.args[0] == 'help': 84 self.args[0] = '--help' 85 86 self.determine_verb() 87 help1 = self.prescan_for_flag("-h", self.help_topics) 88 help2 = self.prescan_for_flag("--help", self.help_topics) 89 self.help_arg: Union[str, bool] 90 if isinstance(help1, bool) and isinstance(help2, bool): 91 self.help_arg = help1 or help2 92 else: 93 self.help_arg = help1 if isinstance(help1, str) else help2 94 95 short_usage = self._usage_string(plugins, self.help_arg) 96 97 self.visible_topics = self.determine_help_topics(self.help_arg) 98 99 # elements are added by .add_group() 100 self.groups: Dict[str, argparse._ArgumentGroup] = {} 101 # elements are added by .parse_args() 102 self.defaults: Dict[str, Any] = {} 103 104 self.parser = configargparse.ArgParser( 105 prog="certbot", 106 usage=short_usage, 107 formatter_class=CustomHelpFormatter, 108 args_for_setting_config_path=["-c", "--config"], 109 default_config_files=flag_default("config_files"), 110 config_arg_help_message="path to config file (default: {0})".format( 111 " and ".join(flag_default("config_files")))) 112 113 # This is the only way to turn off overly verbose config flag documentation 114 self.parser._add_config_file_help = False 115 116 self.verb: str 117 118 # Help that are synonyms for --help subcommands 119 COMMANDS_TOPICS = ["command", "commands", "subcommand", "subcommands", "verbs"] 120 121 def _list_subcommands(self) -> str: 122 longest = max(len(v) for v in VERB_HELP_MAP) 123 124 text = "The full list of available SUBCOMMANDS is:\n\n" 125 for verb, props in sorted(VERB_HELP): 126 doc = props.get("short", "") 127 text += '{0:<{length}} {1}\n'.format(verb, doc, length=longest) 128 129 text += "\nYou can get more help on a specific subcommand with --help SUBCOMMAND\n" 130 return text 131 132 def _usage_string(self, plugins: Iterable[str], help_arg: Union[str, bool]) -> str: 133 """Make usage strings late so that plugins can be initialised late 134 135 :param plugins: all discovered plugins 136 :param help_arg: False for none; True for --help; "TOPIC" for --help TOPIC 137 :rtype: str 138 :returns: a short usage string for the top of --help TOPIC) 139 """ 140 if "nginx" in plugins: 141 nginx_doc = "--nginx Use the Nginx plugin for authentication & installation" 142 else: 143 nginx_doc = "(the certbot nginx plugin is not installed)" 144 if "apache" in plugins: 145 apache_doc = "--apache Use the Apache plugin for authentication & installation" 146 else: 147 apache_doc = "(the certbot apache plugin is not installed)" 148 149 usage = SHORT_USAGE 150 if help_arg is True: 151 self.notify(usage + COMMAND_OVERVIEW % (apache_doc, nginx_doc) + HELP_AND_VERSION_USAGE) 152 sys.exit(0) 153 elif help_arg in self.COMMANDS_TOPICS: 154 self.notify(usage + self._list_subcommands()) 155 sys.exit(0) 156 elif help_arg == "all": 157 # if we're doing --help all, the OVERVIEW is part of the SHORT_USAGE at 158 # the top; if we're doing --help someothertopic, it's OT so it's not 159 usage += COMMAND_OVERVIEW % (apache_doc, nginx_doc) 160 elif isinstance(help_arg, str): 161 custom = VERB_HELP_MAP.get(help_arg, {}).get("usage", None) 162 usage = custom if custom else usage 163 # Only remaining case is help_arg == False, which gives effectively usage == SHORT_USAGE. 164 165 return usage 166 167 def remove_config_file_domains_for_renewal(self, parsed_args: argparse.Namespace) -> None: 168 """Make "certbot renew" safe if domains are set in cli.ini.""" 169 # Works around https://github.com/certbot/certbot/issues/4096 170 if self.verb == "renew": 171 for source, flags in self.parser._source_to_settings.items(): # pylint: disable=protected-access 172 if source.startswith("config_file") and "domains" in flags: 173 parsed_args.domains = _Default() if self.detect_defaults else [] 174 175 def parse_args(self) -> argparse.Namespace: 176 """Parses command line arguments and returns the result. 177 178 :returns: parsed command line arguments 179 :rtype: argparse.Namespace 180 181 """ 182 parsed_args = self.parser.parse_args(self.args) 183 parsed_args.func = self.VERBS[self.verb] 184 parsed_args.verb = self.verb 185 186 self.remove_config_file_domains_for_renewal(parsed_args) 187 188 if self.detect_defaults: 189 return parsed_args 190 191 self.defaults = {key: copy.deepcopy(self.parser.get_default(key)) 192 for key in vars(parsed_args)} 193 194 # Do any post-parsing homework here 195 196 if self.verb == "renew": 197 if parsed_args.force_interactive: 198 raise errors.Error( 199 "{0} cannot be used with renew".format( 200 constants.FORCE_INTERACTIVE_FLAG)) 201 parsed_args.noninteractive_mode = True 202 203 if parsed_args.force_interactive and parsed_args.noninteractive_mode: 204 raise errors.Error( 205 "Flag for non-interactive mode and {0} conflict".format( 206 constants.FORCE_INTERACTIVE_FLAG)) 207 208 if parsed_args.staging or parsed_args.dry_run: 209 self.set_test_server(parsed_args) 210 211 if parsed_args.csr: 212 self.handle_csr(parsed_args) 213 214 if parsed_args.must_staple: 215 parsed_args.staple = True 216 217 if parsed_args.validate_hooks: 218 hooks.validate_hooks(parsed_args) 219 220 if parsed_args.allow_subset_of_names: 221 if any(util.is_wildcard_domain(d) for d in parsed_args.domains): 222 raise errors.Error("Using --allow-subset-of-names with a" 223 " wildcard domain is not supported.") 224 225 if parsed_args.hsts and parsed_args.auto_hsts: 226 raise errors.Error( 227 "Parameters --hsts and --auto-hsts cannot be used simultaneously.") 228 229 if isinstance(parsed_args.key_type, list) and len(parsed_args.key_type) > 1: 230 raise errors.Error( 231 "Only *one* --key-type type may be provided at this time.") 232 233 return parsed_args 234 235 def set_test_server(self, parsed_args: argparse.Namespace) -> None: 236 """We have --staging/--dry-run; perform sanity check and set config.server""" 237 238 # Flag combinations should produce these results: 239 # | --staging | --dry-run | 240 # ------------------------------------------------------------ 241 # | --server acme-v02 | Use staging | Use staging | 242 # | --server acme-staging-v02 | Use staging | Use staging | 243 # | --server <other> | Conflict error | Use <other> | 244 245 default_servers = (flag_default("server"), constants.STAGING_URI) 246 247 if parsed_args.staging and parsed_args.server not in default_servers: 248 raise errors.Error("--server value conflicts with --staging") 249 250 if parsed_args.server in default_servers: 251 parsed_args.server = constants.STAGING_URI 252 253 if parsed_args.dry_run: 254 if self.verb not in ["certonly", "renew"]: 255 raise errors.Error("--dry-run currently only works with the " 256 "'certonly' or 'renew' subcommands (%r)" % self.verb) 257 parsed_args.break_my_certs = parsed_args.staging = True 258 if glob.glob(os.path.join(parsed_args.config_dir, constants.ACCOUNTS_DIR, "*")): 259 # The user has a prod account, but might not have a staging 260 # one; we don't want to start trying to perform interactive registration 261 parsed_args.tos = True 262 parsed_args.register_unsafely_without_email = True 263 264 def handle_csr(self, parsed_args: argparse.Namespace) -> None: 265 """Process a --csr flag.""" 266 if parsed_args.verb != "certonly": 267 raise errors.Error("Currently, a CSR file may only be specified " 268 "when obtaining a new or replacement " 269 "via the certonly command. Please try the " 270 "certonly command instead.") 271 if parsed_args.allow_subset_of_names: 272 raise errors.Error("--allow-subset-of-names cannot be used with --csr") 273 274 csrfile, contents = parsed_args.csr[0:2] 275 typ, csr, domains = crypto_util.import_csr_file(csrfile, contents) 276 277 # This is not necessary for webroot to work, however, 278 # obtain_certificate_from_csr requires parsed_args.domains to be set 279 for domain in domains: 280 add_domains(parsed_args, domain) 281 282 if not domains: 283 # TODO: add CN to domains instead: 284 raise errors.Error( 285 "Unfortunately, your CSR %s needs to have a SubjectAltName for every domain" 286 % parsed_args.csr[0]) 287 288 parsed_args.actual_csr = (csr, typ) 289 290 csr_domains = {d.lower() for d in domains} 291 config_domains = set(parsed_args.domains) 292 if csr_domains != config_domains: 293 raise errors.ConfigurationError( 294 "Inconsistent domain requests:\nFrom the CSR: {0}\nFrom command line/config: {1}" 295 .format(", ".join(csr_domains), ", ".join(config_domains))) 296 297 298 def determine_verb(self) -> None: 299 """Determines the verb/subcommand provided by the user. 300 301 This function works around some of the limitations of argparse. 302 303 """ 304 if "-h" in self.args or "--help" in self.args: 305 # all verbs double as help arguments; don't get them confused 306 self.verb = "help" 307 return 308 309 for i, token in enumerate(self.args): 310 if token in self.VERBS: 311 verb = token 312 if verb == "auth": 313 verb = "certonly" 314 if verb == "everything": 315 verb = "run" 316 self.verb = verb 317 self.args.pop(i) 318 return 319 320 self.verb = "run" 321 322 def prescan_for_flag(self, flag: str, possible_arguments: Iterable[str]) -> Union[str, bool]: 323 """Checks cli input for flags. 324 325 Check for a flag, which accepts a fixed set of possible arguments, in 326 the command line; we will use this information to configure argparse's 327 help correctly. Return the flag's argument, if it has one that matches 328 the sequence @possible_arguments; otherwise return whether the flag is 329 present. 330 331 """ 332 if flag not in self.args: 333 return False 334 pos = self.args.index(flag) 335 try: 336 nxt = self.args[pos + 1] 337 if nxt in possible_arguments: 338 return nxt 339 except IndexError: 340 pass 341 return True 342 343 def add(self, topics: Optional[Union[List[Optional[str]], str]], *args: Any, 344 **kwargs: Any) -> None: 345 """Add a new command line argument. 346 347 :param topics: str or [str] help topic(s) this should be listed under, 348 or None for options that don't fit under a specific 349 topic which will only be shown in "--help all" output. 350 The first entry determines where the flag lives in the 351 "--help all" output (None -> "optional arguments"). 352 :param list *args: the names of this argument flag 353 :param dict **kwargs: various argparse settings for this argument 354 355 """ 356 action = kwargs.get("action") 357 if action is util.DeprecatedArgumentAction: 358 # If the argument is deprecated through 359 # certbot.util.add_deprecated_argument, it is not shown in the help 360 # output and any value given to the argument is thrown away during 361 # argument parsing. Because of this, we handle this case early 362 # skipping putting the argument in different help topics and 363 # handling default detection since these actions aren't needed and 364 # can cause bugs like 365 # https://github.com/certbot/certbot/issues/8495. 366 self.parser.add_argument(*args, **kwargs) 367 return 368 369 if isinstance(topics, list): 370 # if this flag can be listed in multiple sections, try to pick the one 371 # that the user has asked for help about 372 topic = self.help_arg if self.help_arg in topics else topics[0] 373 else: 374 topic = topics # there's only one 375 376 if self.detect_defaults: 377 kwargs = self.modify_kwargs_for_default_detection(**kwargs) 378 379 if isinstance(topic, str) and self.visible_topics[topic]: 380 if topic in self.groups: 381 group = self.groups[topic] 382 group.add_argument(*args, **kwargs) 383 else: 384 self.parser.add_argument(*args, **kwargs) 385 else: 386 kwargs["help"] = argparse.SUPPRESS 387 self.parser.add_argument(*args, **kwargs) 388 389 def modify_kwargs_for_default_detection(self, **kwargs: Any) -> Dict[str, Any]: 390 """Modify an arg so we can check if it was set by the user. 391 392 Changes the parameters given to argparse when adding an argument 393 so we can properly detect if the value was set by the user. 394 395 :param dict kwargs: various argparse settings for this argument 396 397 :returns: a modified versions of kwargs 398 :rtype: dict 399 400 """ 401 action = kwargs.get("action", None) 402 if action not in EXIT_ACTIONS: 403 kwargs["action"] = ("store_true" if action in ZERO_ARG_ACTIONS else 404 "store") 405 kwargs["default"] = _Default() 406 for param in ARGPARSE_PARAMS_TO_REMOVE: 407 kwargs.pop(param, None) 408 409 return kwargs 410 411 def add_deprecated_argument(self, argument_name: str, num_args: int) -> None: 412 """Adds a deprecated argument with the name argument_name. 413 414 Deprecated arguments are not shown in the help. If they are used 415 on the command line, a warning is shown stating that the 416 argument is deprecated and no other action is taken. 417 418 :param str argument_name: Name of deprecated argument. 419 :param int num_args: Number of arguments the option takes. 420 421 """ 422 # certbot.util.add_deprecated_argument expects the normal add_argument 423 # interface provided by argparse. This is what is given including when 424 # certbot.util.add_deprecated_argument is used by plugins, however, in 425 # that case the first argument to certbot.util.add_deprecated_argument 426 # is certbot._internal.cli.HelpfulArgumentGroup.add_argument which 427 # internally calls the add method of this class. 428 # 429 # The difference between the add method of this class and the standard 430 # argparse add_argument method caused a bug in the past (see 431 # https://github.com/certbot/certbot/issues/8495) so we use the same 432 # code path here for consistency and to ensure it works. To do that, we 433 # wrap the add method in a similar way to 434 # HelpfulArgumentGroup.add_argument by providing a help topic (which in 435 # this case is set to None). 436 add_func = functools.partial(self.add, None) 437 util.add_deprecated_argument(add_func, argument_name, num_args) 438 439 def add_group(self, topic: str, verbs: Iterable[str] = (), 440 **kwargs: Any) -> HelpfulArgumentGroup: 441 """Create a new argument group. 442 443 This method must be called once for every topic, however, calls 444 to this function are left next to the argument definitions for 445 clarity. 446 447 :param str topic: Name of the new argument group. 448 :param str verbs: List of subcommands that should be documented as part of 449 this help group / topic 450 451 :returns: The new argument group. 452 :rtype: `HelpfulArgumentGroup` 453 454 """ 455 if self.visible_topics[topic]: 456 self.groups[topic] = self.parser.add_argument_group(topic, **kwargs) 457 if self.help_arg: 458 for v in verbs: 459 self.groups[topic].add_argument(v, help=VERB_HELP_MAP[v]["short"]) 460 return HelpfulArgumentGroup(self, topic) 461 462 def add_plugin_args(self, plugins: disco.PluginsRegistry) -> None: 463 """ 464 465 Let each of the plugins add its own command line arguments, which 466 may or may not be displayed as help topics. 467 468 """ 469 for name, plugin_ep in plugins.items(): 470 parser_or_group = self.add_group(name, 471 description=plugin_ep.long_description) 472 plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name) 473 474 def determine_help_topics(self, chosen_topic: Union[str, bool]) -> Dict[str, bool]: 475 """ 476 477 The user may have requested help on a topic, return a dict of which 478 topics to display. @chosen_topic has prescan_for_flag's return type 479 480 :returns: dict 481 482 """ 483 # topics maps each topic to whether it should be documented by 484 # argparse on the command line 485 if chosen_topic == "auth": 486 chosen_topic = "certonly" 487 if chosen_topic == "everything": 488 chosen_topic = "run" 489 if chosen_topic == "all": 490 # Addition of condition closes #6209 (removal of duplicate route53 option). 491 return {t: t != 'certbot-route53:auth' for t in self.help_topics} 492 elif not chosen_topic: 493 return {t: False for t in self.help_topics} 494 return {t: t == chosen_topic for t in self.help_topics} 495