Repository: certbot/certbot

Issue:
hook commands wrongly documented to be shell commands
Hey.

The `--help` text and https://eff-certbot.readthedocs.io/en/stable/using.html#certbot-command-line-options claim that the hook commands would be commands run in a shell:
```
  --pre-hook PRE_HOOK   Command to be run in a shell before obtaining any
                        certificates.
…
  --post-hook POST_HOOK
                        Command to be run in a shell after attempting to
                        obtain/renew certificates.
…
  --deploy-hook DEPLOY_HOOK
                        Command to be run in a shell once for each
                        successfully issued certificate.
…
```

A command run in a shell is however any valid [shell command as defined by POSIX](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_09) i.e. not just simple commands (and AFAICS even that wouldn't work).

So not only `/path/to/executable` but also e.g. `{ echo foo; /path/to/executable; }`.<br>
The latter does e.g. not seem to work however.

In https://github.com/certbot/certbot/blob/7a6752a68ed77e73c2b29ab20d3ca8927f4fa7b0/certbot/certbot/_internal/hooks.py#L45-L61 however, certbot seems to check whether the "shell command" string starts with a executable, which is however not necessary for shell commands.

It is however indeed started in a shell:
https://github.com/certbot/certbot/blob/7a6752a68ed77e73c2b29ab20d3ca8927f4fa7b0/certbot/certbot/compat/misc.py#L157-L160


Either documentation should be corrected, or better, (all) shell commands be supported.

The above example may not make that much sense, but consider e.g. something like (pseudocode):
```
if isServerRunning; then reloadServer; fi
```
of course that `if` can also be replaced by `[` / `test` and combined with `&&`... but then just think about more complex examples where one might want to use case or so.


Cheers,
Chris.


Discussion:
> The latter does e.g. not seem to work however.

Are you sure? Because when I try the following command:

    certbot certonly -a apache --staging --cert-name testcert -d example.com --deploy-hook 'echo ${RENEWED_DOMAINS} > /tmp/testcert; /bin/ls >> /tmp/testcert'

I did get the variable in the temporary file (so the `echo` command worked) and the output of the `ls` command too.

So I'm wondering if you perhaps didn't enclose the value to deploy hook in (single) quotes for example? And are you running the most recent version of Certbot? (Currently 2.4.0.)

Anyway, "works for me". 🙂 
Well as I said... checks whether there's a executable as the first argument... and in your example there is (`echo` typically exists as a binary, in addition to being shell built in).

Try the same in a subshell like `{ echo … ; }`?
I think I understand your issue. With the check in place, such shell commands *not* starting with an executable script are currently indeed not possible.

However, I'm not sure if the Certbot team would want this any other way. Not sure if it's here on Github or on the Let's Encrypt community, but it has often been advised for people just to add all the required shell stuff into a script instead of putting everything on the command line in a hook option.

That said, the documentation does suggest you could put anything into the hook command, so I *personally* agree that could be improved.
`--disable-hook-validation` will allow something like `--post-hook "{ echo 123; }"` to work.

The issue was raised 7 years ago and there's [a comment from an original/former maintainer](https://github.com/certbot/certbot/issues/3020#issuecomment-220128531) that suggests that the hook validation does do actually something useful, despite its limitation. 

Some potential directions to go:
- Rip out hook validation.
- Clarify the documentation that hooks should refer to executables. This makes it clear that shell builtins are not supported.
- Refer to `--disable-hook-validation` in the error message, so users know that there is an escape hatch.

I'd probably go for the latter.

Resolving patch:
diff --git a/certbot/certbot/_internal/cli/__init__.py b/certbot/certbot/_internal/cli/__init__.py
--- a/certbot/certbot/_internal/cli/__init__.py
+++ b/certbot/certbot/_internal/cli/__init__.py
@@ -387,6 +387,9 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st
     helpful.add(
         ["renew", "reconfigure"], "--pre-hook",
         help="Command to be run in a shell before obtaining any certificates."
+        " Unless --disable-hook-validation is used, the command’s first word"
+        " must be the absolute pathname of an executable or one found via the"
+        " PATH environment variable."
         " Intended primarily for renewal, where it can be used to temporarily"
         " shut down a webserver that might conflict with the standalone"
         " plugin. This will only be called if a certificate is actually to be"
@@ -395,7 +398,11 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st
     helpful.add(
         ["renew", "reconfigure"], "--post-hook",
         help="Command to be run in a shell after attempting to obtain/renew"
-        " certificates. Can be used to deploy renewed certificates, or to"
+        " certificates."
+        " Unless --disable-hook-validation is used, the command’s first word"
+        " must be the absolute pathname of an executable or one found via the"
+        " PATH environment variable."
+        " Can be used to deploy renewed certificates, or to"
         " restart any servers that were stopped by --pre-hook. This is only"
         " run if an attempt was made to obtain/renew a certificate. If"
         " multiple renewed certificates have identical post-hooks, only"
@@ -409,7 +416,11 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st
     helpful.add(
         ["renew", "reconfigure"], "--deploy-hook", action=_DeployHookAction,
         help='Command to be run in a shell once for each successfully'
-        ' issued certificate. For this command, the shell variable'
+        ' issued certificate.'
+        ' Unless --disable-hook-validation is used, the command’s first word'
+        ' must be the absolute pathname of an executable or one found via the'
+        ' PATH environment variable.'
+        ' For this command, the shell variable'
         ' $RENEWED_LINEAGE will point to the config live subdirectory'
         ' (for example, "/etc/letsencrypt/live/example.com") containing'
         ' the new certificates and keys; the shell variable'
diff --git a/certbot/certbot/_internal/hooks.py b/certbot/certbot/_internal/hooks.py
--- a/certbot/certbot/_internal/hooks.py
+++ b/certbot/certbot/_internal/hooks.py
@@ -55,7 +55,8 @@ def validate_hook(shell_cmd: str, hook_name: str) -> None:
                 msg = f"{cmd}-hook command {hook_name} exists, but is not executable."
             else:
                 msg = (
-                    f"Unable to find {hook_name}-hook command {cmd} in the PATH.\n(PATH is {path})"
+                    f"Unable to find {hook_name}-hook command {cmd} in the PATH.\n(PATH is "
+                    f"{path})\nSee also the --disable-hook-validation option."
                 )
 
             raise errors.HookCommandNotFound(msg)


Test patch: