/ certbot

The certbot Hook API

Hopefully this is useful to someone else. I got confused by the language change from renew to deploy hooks and spent some time ripping the code apart to see how the hooks actually work. I've broken down where the hooks are defined, their configuration, and how you can modify them.

Notes

When I started this, certbot was on 0.19. There's a really good chance it's been updated when you're reading this, especially if you're trying to update older usage. I've tried to mention the versions when talking about things, and I've linked the version-tagged files instead of the files from the default branch.

certbot is solid, organic FOSS written by many capable devs, each with different ideas about how to structure a Python repo. I don't have a solid grasp on Python yet. I haven't yet encountered useful tools to break code down and trace execution, so my breakdowns exclusively use the source. My interpretation of the source might be horribly wrong.

When I generalize "hooks" in this post, I'm referring to renew_hooks and deploy_hooks. There are also pre_hooks and post_hooks that probably shouldn't get lumped in. For example, when I say "all the hooks are renew_hooks", I'm not including (pre|post)_hooks. I explicitly mention them when they're pertinent.

Finally, I'm releasing this without all the code I wanted to write. It's been on the backburner for a couple of weekends. I had originally planned to do a more detailed execution analysis with examples, but I need some of this info in a post I'm working on now. I'm still planning on (eventually) doing that, so stay tuned.

Overview

I had some SSL issues several weekends ago. Something apparently went wrong with my cron job, and, while trying to verify everything, I realized the CLI flags were different. I was running this command (with absolute paths to mimic cron):

$ /usr/bin/certbot renew --quiet --renew-hook "systemctl restart nginx"

However, I couldn't find it mentioned in the docs:

$ /usr/bin/certbot --help renew | grep -- --renew-hook || (state=$? && echo "whoops" && $(exit $state))
whoops

I did notice --deploy-hook after reading the entire output:

$ /usr/bin/certbot --help renew | awk 'BEGIN { inside_desired_block = 0; }; /^\s*--deploy-hook/{ inside_desired_block = 1; }; (/^\s*--/ && !/^\s*--deploy-hook/){ inside_desired_block = 0; }; inside_desired_block { print; }'
  --deploy-hook DEPLOY_HOOK
                        Command to be run in a shell once for each
                        successfully issued certificate. 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
                        $RENEWED_DOMAINS will contain a space-delimited list
                        of renewed certificate domains (for example,
                        "example.com www.example.com" (default: None)

That sounded exactly like what I remembered reading about --renew-hook.

Initial Change

As of 0.17,

The --renew-hook flag has been hidden in favor of --deploy-hook.

renew_hook was, essentially, hidden behind an adapter, deploy_hook. My best guess for reasoning is that the functions involved are actually triggered whenever a cert is successfully built, which happens on run, certonly, and renew. Anything passed as a deploy_hook was duplicated as a renew_hook, which the major difference being, quite literally, the function called to access them.

(Note: nailing down the precise design pattern used here requires more pedantry than I'm willing to invest right now. deploy_hook, at inception, was an adapter/wrapper/convenience method that calls renew_hook when a renew_hook is called a deploy_hook instead of a renew_hook.)

CLI

Hook Definitions

  • renew_hook was unchanged in hooks.py (aside from some output language).
  • deploy_hook was created as an adapter to renew_hook. The conditional is deceptive; config.deploy_hook == config.renew_hook so there's no reason to not just explicitly call renew_hook.

Execution

When a cert is installed for any reason (renew|certonly|run),

  1. execute pre_hook
  2. execute post_hook

Current API

This section uses tag v0.19.0, which was current when I wrote this.

CLI

There weren't many changes from 0.17 in the CLI setup.

External Hooks

certbot can pick up hooks from its configuration file. I can't find actual documentation of this feature (e.g. ctrl+f renew_hook), but it's built by renewal. Specifically, you can create something like this:

...
[renewalparams]
renew_hook = echo 'do stuff'
...

This will be loaded as a renew_hook after bootstrapping, so, in theory, it will only be executed by renew_hook (unlike CLI-created deploy_hooks, for example), but I haven't tested that. They're loaded by restore_required_config_elements, via _reconstitute, via handle_renewal_request, which seems to only appear in main, where it's called by renew.

Similarly, you can define global hooks, placed in (most likely) /etc/letsencrypt/renewal-hooks/deploy. As explained below, these are not executed by deploy_hook but rather by renew_hook, i.e. they're only going to run with renew. You can possibly trigger them by explicitly setting a deploy_hook via the CLI, to force creation of the renew_hook attribute, but I haven't tested this.

Hook Definitions

The hooks have been extensively refactored (which kinda happens often at major version 0). deploy_hook is similar in name to the primary runner, _run_deploy_hook. However, the deploy_hook function is much slimmer than renew_hook, implying a continued reliance on renew_hook.

Execution

This breakdown is a bit messier than before, with a few more branches to trace. When a cert is installed for any reason (renew|certonly|run),

  1. execute pre_hook:
  2. execute post_hook:

So What?

Expect Change

In the couple of weekends it took me to get back to this post, v0.20 was released. I haven't had a chance to look at it yet. This is great, active FOSS. Don't expect the minutae to work as intended for awhile yet. To quote semver,

Major version zero (0.y.z) is for initial development. Anything may change at any time. The public API should not be considered stable.

Manually Run Hooks After Initial Creation

For some reason there's a disconnect between deploy_hook and renew_hook on installation (confirmed in v0.20). If you've built an external hook in /etc/letsencrypt/renewal-hooks/deploy that, say, restarts your webserver, it's not going to get triggered with a brand new cert. However, it will get run every time you renew afterward.

Create a Generic Server Restart Hook

Something like this in /etc/letsencrypt/renewal-hooks/deploy will successfully reload any new configuration on any of your new sites (assuming they're all run by the same webserver). It's only going to get triggered on a successful update, so it shouldn't run wild.

Nginx

#!/bin/bash

nginx -t && systemctl restart nginx

Apache

Might have to use apache2ctl or httpd.

#!/bin/bash

apachectl -t && systemctl restart apachectl

Pre- and Post-Hooks are Always Run

No matter what, certbot starts with pre_hooks and finishes with post_hooks. I don't have a good use for either, so I'm a bit short on examples. However, know the hooks are there and can be used.

Final Note

Let's Encrypt is a fantastic service. If you like what they do, i.e. appreciate how accessible they've made secure web traffic, please donate. EFF's certbot is what powers my site (and basically anything I work on these days); consider buying them a beer (it's really just a donate link but you catch my drift).

CJ Harries

I did a thing once. Change "blog." to "cj@" and you've got my email. All these opinions are mine and might not be shared by clients or employers.

Read More