A simple hook engine for Lego.
Find a file
2025-03-17 14:19:02 -04:00
bin initial commit 2025-03-17 14:19:02 -04:00
lib/lego-hook initial commit 2025-03-17 14:19:02 -04:00
share/lego-hook initial commit 2025-03-17 14:19:02 -04:00
README.md initial commit 2025-03-17 14:19:02 -04:00

lego-hook

A simple hook engine for Lego.

Installation

  1. Copy the contents of bin/ to /usr/local/bin/
  2. Copy the contents of lib/lego-hook/ to /usr/local/lib/lego-hook/
  3. Copy the contents of share/lego-hook/ to /usr/local/share/lego-hook/

Usage

Preface

lego-hook assumes the lego directory is always /etc/lego/. When generating or renewing certificates, you should use lego's path argument to have it work in this directory. Alternatively, you can specify the LEGO_GLOBAL_DIR environment variable to override the default value.

About hooks

"Hooks" are files containing POSIX shell functions and variables, which are interpreted by the lego-hook script. Hooks are divided into "stages", which are the individual shell functions in a hook file. The lego-hook utility sources the hook into a subshell, and executes each stage in the order specified by the LEGO_HOOK_STAGES shell variable. Any stages not listed in LEGO_HOOK_STAGES will not be executed. Helper functions are supplied to abstract complex tasks.

The following helper functions are available:

  • do_ssh
    • Runs commands on a remote system over SSH
    • Example: do_ssh "umask 700; mkdir -pv ${LEGO_PATH}/certificates"
    • The command(s) have to be quoted, as they must be expanded on the server side. Unexpected behavior will occur if not quoted correctly.
  • do_scp
    • Copies a file to a remote system over SFTP
    • Example: ```do_scp "${LEGO_CERT_PATH}" "${LEGO_CERT_PATH}"``

Writing hooks

All hooks must begin with the following shebang: #!/usr/bin/env lego-hook

LEGO_HOOK_STAGES must be defined at the top of your hook, as it contains a space-seperated list of stages for lego-hook to execute. - Example: LEGO_HOOK_STAGES="clean prepare deploy finalize"

Alternatively, LEGO_HOOK_STAGES can be defined with the default parameter construct if you want to be able to override the stage list with an environment variable. - Example: : "${LEGO_HOOK_STAGES:='clean prepare deploy finalize'}"

The following parameters are also required if using the do_ssh or do_scp helpers:

  • LEGO_SSH_HOST_KEY_ALGORITHM
    • Algorithm to use when validating remote system's SSH host key.
    • A list of host key fingerprints and their associated algorithm can be obtained using ssh-keyscan -q <hostname or ip>
    • If the remote host supports it, ssh-ed25519 is likely the algorithm you want to use, as it's faster and more robust than the other options.
  • LEGO_SSH_HOST_KEY_FINGERPRINT
    • Expected SSH host key fingerprint for the algorithm specified above.
    • The fingerprint for a specific algorithm can be obtained using ssh-keyscan -q -t <algorithm> <hostname or ip>
  • LEGO_SSH_HOST
    • The remote system to SSH into.
  • LEGO_SSH_USER
    • The remote user to authenticate as.
    • Only key-based authentication is supported for security reasons.
    • The remote user must trust the SSH key of the local user executing the hook.

All environment variables, including those exported by lego, are also available for use in hooks. Additionally, a variable called LEGO_CERT_BASENAME is provided, which contains the basename of the certificate and key without the extension. For example, if the certificate is for *.example.com, LEGO_CERT_BASENAME will contain_.example.com

A sample hook is located in share/lego-hook/example.hook.

Running hooks

  1. Create a directory called hooks/ in the lego directory.
  2. Inside the hooks/ directory, create a new directory named after each certificate domain you wish to associate hooks with. Replace all asterisks with underscores.
    • For example, /etc/lego/hooks/example.com and /etc/lego/hooks/_.example.com/ are both valid hook directories.
  3. For each certificate domain, copy the associated hooks to the newly created directories. Hooks are processed alphabetically, so they can be ordered by prepending a number to the file name.
    • For example, 00-internal-proxy, 01-external-proxy, 02-mail, etc.
    • Symlinks are also acceptable.

Simply run lego with the hook argument set to lego-runhooks. lego-runhooks will automatically execute all hooks in the Lego directory for the domain located in LEGO_CERT_DOMAIN.

Debugging hooks

Individual hooks can be run directly from the shell for debugging purposes. You should specify each of the environment variables provided by lego, but if you simply specify LEGO_CERT_DOMAIN, an attempt will be made to infer the rest of the required environment variables.

You can run the hooks as the first argument to the lego-hook command, or by marking them as executable and running them directly on the shell. The behavior is the same for both methods, but the latter is availble for conciseness.

Design Decisions

Why shell scripts?

A POSIX(-ish)1 shell script is arguably the most portable way to do anything on a UNIX-like system. Portability was my highest concern, as this will be running arbitrary commands on remote systems. Planning an implementation in Go, which should give us far more flexibility to do more tricks. The original plan was to wrap the entire process, including cert generation (w/ Lego) in a series of shell scripts, but this proved to be excessively complex, fragile, and unmaintainable. This may be more approachable with a Go implementation. Currently unsure if I'd keep the shell hook approach or if I'd switch to an embedded scripting language like Lua.

Have you ensured that the scripts are safe?

Yes, to the best of my ability. I've worked through the whole flow of the project multiple times to verify correctness of the implementation, a process which led to several refactors. I've also used shellcheck as a second-measure to verify the syntactic correctness of the scripts. Once I'm sure I'm not going to port this to a compiled language, I plan to also write a test suite to prevent regressions in the correctness of the implementation.

SSHing into remote systems?

This also felt odd at first glance, but it actually makes sense once you think about it beyond the surface-level. SSH provides it's own well-tested authentication and encryption mechanisms, which means I don't have to (poorly) re-invent the wheel. Additionally, SSH is practically a default package on all modern systems, so no extra software should be required on the remote side. For these reasons, using SSH as a transport made more sense then anything else I could think of. Any other choice would likely be an unmaintable mess.

In addition, only key-based authentication is supported by the helper functions, so this discourages leaving PasswordAuthentication enabled for sshd.

How can you be sure you aren't leaking secret keys to the wrong hosts?

Using SSH as a transport allows us to be reasonably confident we aren't doing. Supplying a host key fingerprint is mandatory for the do_ssh and do_scp helpers, so you can cryptographically verify that you're talking to the correct device. This will not protect you if the remote system itself is compromised, but not much can at that point except maybe hardware attestation of some kind. Unless you're trying to avoid leaking secret keys to Mossad2, this probably is adequate for your threat model.

But, if you're deploying certificates to remote hosts, aren't you escrowing private keys on the system running lego-hook?

Unfortunately, yes. This is due to practical limitations. lego-hook deploys certs by generating them locally and pushing them to the hosts. I designed this system for an environment that primarily uses wildcard certificates, which makes this approach a practical neccesity. For certificates with a narrower scope, this poses a significant limitation. Running Lego locally on each system (using the HTTP-01 challenge) is likely more fit for this purpose, and if that's the case, you likely don't need complex hooks (and by extension, this package).

Unless you are okay with the key-escrowing behavior, you should not use the remote deployment functionality of this package for narrow-scoped certificates.

Known Limitations

  • Remote systems must support SFTP for do_scp helper function to work. There is certainly a way to do this with cat and /dev/stdin tricks, but this was the path of least resistance for a working proof of concept. I plan to implement this later.
  • Default config assumes that Lego is run as root. This is not good practice, but is currently required for practical reasons as you'll see below.
  • Normal users typically won't be able to restart services, so hooks that run locally must be executed as root.
  • The above also applies to remote systems, and thus in most cases we have to SSH in as root too.
  • There is likely a way to do this with privilege seperation, though it may not be possible with just a shell script.
    • Current idea is a privileged daemon that creates a FIFO that a specific unprivileged user can write to. Writing to the FIFO would trigger the deployment.
    • The trade off would be losing the ability to deploy certificates without any additional components installed on the remote system.

Footnotes


  1. Currently type -t is used to verify that stages exist in our hook files, which is not POSIX compliant, but works everywhere I've tested it. ↩︎

  2. See James Mickens' "This World of Ours" paper for context: https://scholar.harvard.edu/files/mickens/files/thisworldofours.pdf ↩︎