Repository: certbot/certbot

Issue:
AttributeError: 'NoneType' object has no attribute 'dest'
If you're having trouble using Certbot and aren't sure you've found a bug or
request for a new feature, please first try asking for help at
https://community.letsencrypt.org/. There is a much larger community there of
people familiar with the project who will be able to more quickly answer your
questions.

## My operating system is (include version):
[root@puppet tmp]# lsb_release -a
LSB Version:    :core-4.1-amd64:core-4.1-noarch:cxx-4.1-amd64:cxx-4.1-noarch:desktop-4.1-amd64:desktop-4.1-noarch:languages-4.1-amd64:languages-4.1-noarch:printing-4.1-amd64:printing-4.1-noarch
Distributor ID: CentOS
Description:    CentOS Linux release 7.9.2009 (Core)
Release:        7.9.2009
Codename:       Core

## I installed Certbot with (snap, OS package manager, pip, certbot-auto, etc):
Snap

## I ran this command and it produced this output:
[root]# certbot certonly -d some_host
Use of --manual-public-ip-logging-ok is deprecated.
An unexpected error occurred:
AttributeError: 'NoneType' object has no attribute 'dest'
Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /tmp/certbot-log-2g1inyh5/log or re-run Certbot with -v for more details.

## Certbot's behavior differed from what I expected because:
I expected Certbot to output something other than an error message. This also fails when trying to run certbot without any actions or options.

## Here is a Certbot log showing the issue (if available):
###### Logs are stored in `/var/log/letsencrypt` by default. Feel free to redact domains, e-mail and IP addresses as you see fit.
2023-10-09 15:29:52,062:DEBUG:urllib3.connectionpool:http://localhost:None "GET /v2/connections?snap=certbot&interface=content HTTP/1.1" 200 97
2023-10-09 15:29:52,194:DEBUG:certbot._internal.main:certbot version: 2.7.0
2023-10-09 15:29:52,194:DEBUG:certbot._internal.main:Location of certbot entry point: /snap/certbot/3369/bin/certbot
2023-10-09 15:29:52,194:DEBUG:certbot._internal.main:Arguments: ['-d', 'some_host', '--preconfigured-renewal']
2023-10-09 15:29:52,194:DEBUG:certbot._internal.main:Discovered plugins: PluginsRegistry(PluginEntryPoint#apache,PluginEntryPoint#manual,PluginEntryPoint#nginx,PluginEntryPoint#null,PluginEntryPoint#standalone,PluginEntryPoint#webroot)
2023-10-09 15:29:52,200:WARNING:certbot.util:Use of --manual-public-ip-logging-ok is deprecated.
2023-10-09 15:29:52,201:DEBUG:certbot._internal.log:Exiting abnormally:
Traceback (most recent call last):
  File "/snap/certbot/3369/bin/certbot", line 8, in <module>
    sys.exit(main())
  File "/var/lib/snapd/snap/certbot/3369/lib/python3.8/site-packages/certbot/main.py", line 19, in main
    return internal_main.main(cli_args)
  File "/var/lib/snapd/snap/certbot/3369/lib/python3.8/site-packages/certbot/_internal/main.py", line 1852, in main
    config = cli.prepare_and_parse_args(plugins, cli_args)
  File "/var/lib/snapd/snap/certbot/3369/lib/python3.8/site-packages/certbot/_internal/cli/__init__.py", line 476, in prepare_and_parse_args
    return helpful.parse_args()
  File "/var/lib/snapd/snap/certbot/3369/lib/python3.8/site-packages/certbot/_internal/cli/helpful.py", line 228, in parse_args
    config.set_argument_sources(self._build_sources_dict())
  File "/var/lib/snapd/snap/certbot/3369/lib/python3.8/site-packages/certbot/_internal/cli/helpful.py", line 198, in _build_sources_dict
    update_result(source_to_settings_dict[source_key], ArgumentSource.CONFIG_FILE)
  File "/var/lib/snapd/snap/certbot/3369/lib/python3.8/site-packages/certbot/_internal/cli/helpful.py", line 193, in update_result
    result.update({ action.dest: source for action in actions})
  File "/var/lib/snapd/snap/certbot/3369/lib/python3.8/site-packages/certbot/_internal/cli/helpful.py", line 193, in <dictcomp>
    result.update({ action.dest: source for action in actions})
AttributeError: 'NoneType' object has no attribute 'dest'
2023-10-09 15:29:52,201:ERROR:certbot._internal.log:An unexpected error occurred:
2023-10-09 15:29:52,201:ERROR:certbot._internal.log:AttributeError: 'NoneType' object has no attribute 'dest'

## LetsEncrypt cli.ini:
email = REDACTED
manual = true
manual-auth-hook = /usr/local/libexec/certbot-ddns-cname --ttl 1
manual-cleanup-hook = /usr/local/libexec/certbot-ddns-cname -m cleanup
manual-public-ip-logging-ok = true
post-hook = /etc/letsencrypt/renewal-hooks/post/certbot-linker.sh
preferred-chain = ISRG Root X1
preferred-challenge = dns
webroot-path = /var/www/html

## Here is the relevant nginx server block or Apache virtualhost for the domain I am configuring:



Discussion:
FWIW we did not experience this on v2.6
@wgreenberg, this also seems related to https://github.com/certbot/certbot/pull/9678 right? Let me know if you'd like me to help dig into this.

~~I think I'm going to delay the 2.7.1 release for now which doesn't seem like a big deal as I haven't seen people clamoring for a fix for https://github.com/certbot/certbot/issues/9786 so we could potentially do something here for that point release if we wanted to.~~

EDIT: On second thought, I'm going to go ahead with the release. We can always do another if we want to. Sorry for the extra noise.
Looking into it! and yeah +1 that we can just do another hotfix

Resolving patch:
diff --git a/certbot/certbot/_internal/cli/helpful.py b/certbot/certbot/_internal/cli/helpful.py
--- a/certbot/certbot/_internal/cli/helpful.py
+++ b/certbot/certbot/_internal/cli/helpful.py
@@ -187,10 +187,12 @@ def _build_sources_dict(self) -> Dict[str, ArgumentSource]:
         #   2. config files
         #   3. env vars (shouldn't be any)
         #   4. command line
+
         def update_result(settings_dict: Dict[str, Tuple[configargparse.Action, str]],
                           source: ArgumentSource) -> None:
-            actions = [action for _, (action, _) in settings_dict.items()]
-            result.update({ action.dest: source for action in actions})
+            actions = [self._find_action_for_arg(arg) if action is None else action
+                       for arg, (action, _) in settings_dict.items()]
+            result.update({ action.dest: source for action in actions })
 
         # config file sources look like "config_file|<name of file>"
         for source_key in source_to_settings_dict:
@@ -203,17 +205,59 @@ def update_result(settings_dict: Dict[str, Tuple[configargparse.Action, str]],
         if 'command_line' in source_to_settings_dict:
             settings_dict: Dict[str, Tuple[None, List[str]]]
             settings_dict = source_to_settings_dict['command_line'] # type: ignore
-            (_, args) = settings_dict['']
-            args = [arg for arg in args if arg.startswith('-')]
+            (_, unprocessed_args) = settings_dict['']
+            args = []
+            for arg in unprocessed_args:
+                # ignore non-arguments
+                if not arg.startswith('-'):
+                    continue
+
+                # special case for config file argument, which we don't have an action for
+                if arg in ['-c', '--config']:
+                    result['config_dir'] = ArgumentSource.COMMAND_LINE
+                    continue
+
+                if '=' in arg:
+                    arg = arg.split('=')[0]
+
+                if arg.startswith('--'):
+                    args.append(arg)
+                # for short args (ones that start with a single hyphen), handle
+                # the case of multiple short args together, e.g. "-tvm"
+                else:
+                    for short_arg in arg[1:]:
+                        args.append(f"-{short_arg}")
+
             for arg in args:
                 # find the action corresponding to this arg
-                for action in self.actions:
-                    if arg in action.option_strings:
-                        result[action.dest] = ArgumentSource.COMMAND_LINE
-                        continue
+                action = self._find_action_for_arg(arg)
+                result[action.dest] = ArgumentSource.COMMAND_LINE
 
         return result
 
+    def _find_action_for_arg(self, arg: str) -> configargparse.Action:
+        # Finds a configargparse Action which matches the given arg, where arg
+        # can either be preceded by hyphens (as on the command line) or not (as
+        # in config files)
+
+        # if the argument doesn't have leading hypens, prefix it so it can be
+        # compared directly w/ action option strings
+        if arg[0] != '-':
+            arg = '--' + arg
+
+        # first, check for exact matches
+        for action in self.actions:
+            if arg in action.option_strings:
+                return action
+
+        # now check for abbreviated (i.e. prefix) matches
+        for action in self.actions:
+            for option_string in action.option_strings:
+                if option_string.startswith(arg):
+                    return action
+
+        raise AssertionError(f"Action corresponding to argument {arg} is None")
+
     def parse_args(self) -> NamespaceConfig:
         """Parses command line arguments and returns the result.
 


Test patch:
diff --git a/certbot/certbot/_internal/tests/cli_test.py b/certbot/certbot/_internal/tests/cli_test.py
--- a/certbot/certbot/_internal/tests/cli_test.py
+++ b/certbot/certbot/_internal/tests/cli_test.py
@@ -552,6 +552,48 @@ def test_parse_with_multiple_argument_sources(self):
             ])
             assert_value_and_source(namespace, 'server', COMMAND_LINE_VALUE, ArgumentSource.COMMAND_LINE)
 
+    def test_abbreviated_arguments(self):
+        # Argparse's "allow_abbrev" option (which is True by default) allows
+        # for unambiguous partial arguments (e.g. "--preferred-chal dns" will be
+        # interepreted the same as "--preferred-challenges dns")
+        namespace = self.parse('--preferred-chal dns --no-dir')
+        assert_set_by_user_with_value(namespace, 'pref_challs', ['dns-01'])
+        assert_set_by_user_with_value(namespace, 'directory_hooks', False)
+
+        with tempfile.NamedTemporaryFile() as tmp_config:
+            tmp_config.close()  # close now because of compatibility issues on Windows
+            with open(tmp_config.name, 'w') as file_h:
+                file_h.write('preferred-chal = dns')
+
+            namespace = self.parse([
+                'certonly',
+                '--config', tmp_config.name,
+            ])
+            assert_set_by_user_with_value(namespace, 'pref_challs', ['dns-01'])
+
+    @mock.patch('certbot._internal.hooks.validate_hooks')
+    def test_argument_with_equals(self, unsused_mock_validate_hooks):
+        namespace = self.parse('-d=example.com')
+        assert_set_by_user_with_value(namespace, 'domains', ['example.com'])
+
+        # make sure it doesn't choke on equals signs being present in the argument value
+        plugins = disco.PluginsRegistry.find_all()
+        namespace = cli.prepare_and_parse_args(plugins, ['run', '--pre-hook="foo=bar"'])
+        assert_set_by_user_with_value(namespace, 'pre_hook', '"foo=bar"')
+
+    def test_adjacent_short_args(self):
+        namespace = self.parse('-tv')
+        assert_set_by_user_with_value(namespace, 'text_mode', True)
+        assert_set_by_user_with_value(namespace, 'verbose_count', 1)
+
+        namespace = self.parse('-tvvv')
+        assert_set_by_user_with_value(namespace, 'text_mode', True)
+        assert_set_by_user_with_value(namespace, 'verbose_count', 3)
+
+        namespace = self.parse('-tvm foo@example.com')
+        assert_set_by_user_with_value(namespace, 'text_mode', True)
+        assert_set_by_user_with_value(namespace, 'verbose_count', 1)
+        assert_set_by_user_with_value(namespace, 'email', 'foo@example.com')
 
 if __name__ == '__main__':
     sys.exit(pytest.main(sys.argv[1:] + [__file__]))  # pragma: no cover