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