Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding pre- and post-hook #898

Merged
merged 9 commits into from
Mar 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ jobs:
permissions_default,
permissions_custom,
symlinks,
acme_hooks,
]
setup: [2containers, 3containers]
acme-ca: [pebble]
Expand Down
12 changes: 12 additions & 0 deletions app/letsencrypt_service
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,18 @@ function update_cert {
--fullchain-file "${certificate_dir}/fullchain.pem" \
)

# acme.sh pre and post hooks
local -n acme_pre_hook="ACME_${cid}_PRE_HOOK"
acme_pre_hook=${acme_pre_hook:-$ACME_PRE_HOOK}
if [[ -n "${acme_pre_hook// }" ]]; then
params_issue_arr+=(--pre-hook "$acme_pre_hook")
fi
local -n acme_post_hook="ACME_${cid}_POST_HOOK"
acme_post_hook=${acme_post_hook:-$ACME_POST_HOOK}
if [[ -n "${acme_post_hook// }" ]]; then
params_issue_arr+=(--post-hook "$acme_post_hook")
fi

[[ ! -d "$config_home" ]] && mkdir -p "$config_home"
params_base_arr+=(--config-home "$config_home")
local account_file="${config_home}/ca/${ca_dir}/account.json"
Expand Down
6 changes: 6 additions & 0 deletions app/letsencrypt_service_data.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ LETSENCRYPT_CONTAINERS=(
{{ $EAB_HMAC_KEY := trim (coalesce $container.Env.ACME_EAB_HMAC_KEY "") }}
{{ $ZEROSSL_API_KEY := trim (coalesce $container.Env.ZEROSSL_API_KEY "") }}
{{ $RESTART_CONTAINER := trim (coalesce $container.Env.LETSENCRYPT_RESTART_CONTAINER "") }}
{{ $PRE_HOOK := trim (coalesce $container.Env.ACME_PRE_HOOK "") }}
{{ $POST_HOOK := trim (coalesce $container.Env.ACME_POST_HOOK "") }}
{{ $cid := printf "%.12s" $container.ID }}
{{ if parseBool (coalesce $container.Env.LETSENCRYPT_SINGLE_DOMAIN_CERTS "false") }}
{{/* Explicit per-domain splitting of the certificate */}}
Expand All @@ -49,6 +51,8 @@ LETSENCRYPT_CONTAINERS=(
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_EAB_HMAC_KEY="{{ $EAB_HMAC_KEY }}"
{{- "\n" }}ZEROSSL_{{ $cid }}_{{ $hostHash }}_API_KEY="{{ $ZEROSSL_API_KEY }}"
{{- "\n" }}LETSENCRYPT_{{ $cid }}_{{ $hostHash }}_RESTART_CONTAINER="{{ $RESTART_CONTAINER }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_PRE_HOOK="{{ $PRE_HOOK }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_POST_HOOK="{{ $POST_HOOK }}"
{{ end }}
{{ else }}
{{/* Default: multi-domain (SAN) certificate */}}
Expand All @@ -69,6 +73,8 @@ LETSENCRYPT_CONTAINERS=(
{{- "\n" }}ACME_{{ $cid }}_EAB_HMAC_KEY="{{ $EAB_HMAC_KEY }}"
{{- "\n" }}ZEROSSL_{{ $cid }}_API_KEY="{{ $ZEROSSL_API_KEY }}"
{{- "\n" }}LETSENCRYPT_{{ $cid }}_RESTART_CONTAINER="{{ $RESTART_CONTAINER }}"
{{- "\n" }}ACME_{{ $cid }}_PRE_HOOK="{{ $PRE_HOOK }}"
{{- "\n" }}ACME_{{ $cid }}_POST_HOOK="{{ $POST_HOOK }}"
{{ end }}
{{ end }}
{{ end }}
6 changes: 5 additions & 1 deletion docs/Container-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ You can also create test certificates per container (see [Test certificates](./L

* `CA_BUNDLE` - This is a test only variable [for use with Pebble](https://github.com/letsencrypt/pebble#avoiding-client-https-errors). It changes the trusted root CA used by `acme.sh`, from the default Alpine trust store to the CA bundle file located at the provided path (inside the container). Do **not** use it in production unless you are running your own ACME CA.

* `CERTS_UPDATE_INTERVAL` - 3600 seconds by default, this defines how often the container will check if the certificates require update.
* `CERTS_UPDATE_INTERVAL` - 3600 seconds by default, this defines how often the container will check if the certificates require update.

* `ACME_PRE_HOOK` - The provided command will be run before every certificate issuance. The action is limited to the commands available inside the **acme-companion** container. For example `--env "ACME_PRE_HOOK=echo 'start'"`. For more information see [Pre- and Post-Hook](./Hooks.md)

* `ACME_POST_HOOK` - The provided command will be run after every certificate issuance. The action is limited to the commands available inside the **acme-companion** container. For example `--env "ACME_POST_HOOK=echo 'end'"`. For more information see [Pre- and Post-Hook](./Hooks.md)
70 changes: 70 additions & 0 deletions docs/Hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
## Pre-Hooks and Post-Hooks

The Pre- and Post-Hooks of [acme.sh](https://github.com/acmesh-official/acme.sh/) are available through the corresponding environment variables. This allows to trigger actions just before and after certificates are issued (see [acme.sh documentation](https://github.com/acmesh-official/acme.sh/wiki/Using-pre-hook-post-hook-renew-hook-reloadcmd)).

If you set `ACME_PRE_HOOK` and/or `ACME_POST_HOOK` on the **acme-companion** container, **the actions for all certificates will be the same**. If you want specific actions to be run for specific certificates, set the `ACME_PRE_HOOK` / `ACME_POST_HOOK` environment variable(s) on the proxied container(s) instead. Default (on the **acme-companion** container) and per-container `ACME_PRE_HOOK` / `ACME_POST_HOOK` environment variables aren't combined : if both default and per-container variables are set for a given proxied container, the per-container variables will take precedence over the default.

If you want to run the same default hooks for most containers but not for some of them, you can set the `ACME_PRE_HOOK` / `ACME_POST_HOOK` environment variables to the Bash noop operator (ie, `ACME_PRE_HOOK=:`) on those containers.

#### Pre-Hook: `ACME_PRE_HOOK`
This command will be run before certificates are issued.

For example `echo 'start'` on the **acme-companion** container (setting a default Pre-Hook):
```shell
$ docker run --detach \
--name nginx-proxy-acme \
--volumes-from nginx-proxy \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
--volume acme:/etc/acme.sh \
--env "DEFAULT_EMAIL=mail@yourdomain.tld" \
--env "ACME_PRE_HOOK=echo 'start'" \
nginxproxy/acme-companion
```

And on a proxied container (setting a per-container Pre-Hook):
```shell
$ docker run --detach \
--name your-proxyed-app \
--env "VIRTUAL_HOST=yourdomain.tld" \
--env "LETSENCRYPT_HOST=yourdomain.tld" \
--env "ACME_PRE_HOOK=echo 'start'" \
nginx
```

#### Post-Hook: `ACME_POST_HOOK`
This command will be run after certificates are issued.

For example `echo 'end'` on the **acme-companion** container (setting a default Post-Hook):
```shell
$ docker run --detach \
--name nginx-proxy-acme \
--volumes-from nginx-proxy \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
--volume acme:/etc/acme.sh \
--env "DEFAULT_EMAIL=mail@yourdomain.tld" \
--env "ACME_POST_HOOK=echo 'end'" \
nginxproxy/acme-companion
```

And on a proxied container (setting a per-container Post-Hook):
```shell
$ docker run --detach \
--name your-proxyed-app \
--env "VIRTUAL_HOST=yourdomain.tld" \
--env "LETSENCRYPT_HOST=yourdomain.tld" \
--env "ACME_POST_HOOK=echo 'start'" \
nginx
```

#### Verification:
If you want to check wether the hook-command is delivered properly to [acme.sh](https://github.com/acmesh-official/acme.sh/), you should check `/etc/acme.sh/[EMAILADDRESS]/[DOMAIN]/[DOMAIN].conf`.
The variable `Le_PreHook` contains the Pre-Hook-Command base64 encoded.
The variable `Le_PostHook` contains the Pre-Hook-Command base64 encoded.

#### Limitations
* The commands that can be used in the hooks are limited to the commands available inside the **acme-companion** container. `curl` and `wget` are available, therefore it is possible to communicate with tools outside the container via HTTP, allowing for complex actions to be implemented outside or in other containers.

#### Use-cases
* Changing some firewall rules just for the ACME authorization, so the ports 80 and/or 443 don't have to be publicly reachable at all time.
* Certificate "post processing" / conversion to another format.
* Monitoring.
5 changes: 5 additions & 0 deletions docs/Let's-Encrypt-and-ACME.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ If the ACME CA provides multiple cert chain, you can use the `ACME_PREFERRED_CHA

The `LETSENCRYPT_RESTART_CONTAINER` environment variable, when set to `true` on an application container, will restart this container whenever the corresponding cert (`LETSENCRYPT_HOST`) is renewed. This is useful when certificates are directly used inside a container for other purposes than HTTPS (e.g. an FTPS server), to make sure those containers always use an up to date certificate.

#### Pre-Hook and Post-Hook

The `ACME_PRE_HOOK` and `ACME_POST_HOOK` let you use the [`acme.sh` Pre- and Post-Hooks feature](https://github.com/acmesh-official/acme.sh/wiki/Using-pre-hook-post-hook-renew-hook-reloadcmd) to run commands respectively before and after the container's certificate has been issued. For more information see [Pre- and Post-Hook](./Hooks.md)


### global (set on acme-companion container)

#### Default contact address
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

[Zero SSL](./Zero-SSL.md)

[Pre-Hooks and Post-Hooks](./Hooks.md)

#### Troubleshooting:

[Invalid / failing authorizations](./Invalid-authorizations.md)
1 change: 1 addition & 0 deletions test/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ globalTests+=(
permissions_default
permissions_custom
symlinks
acme_hooks
)

# The ocsp_must_staple test does not work with Pebble
Expand Down
1 change: 1 addition & 0 deletions test/tests/acme_hooks/expected-std-out.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

119 changes: 119 additions & 0 deletions test/tests/acme_hooks/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/bin/bash

## Test for the hooks of acme.sh

default_pre_hook_file="/tmp/default_prehook"
default_pre_hook_command="touch $default_pre_hook_file"
default_post_hook_file="/tmp/default_posthook"
default_post_hook_ommand="touch $default_post_hook_file"

percontainer_pre_hook_file="/tmp/percontainer_prehook"
percontainer_pre_hook_command="touch $percontainer_pre_hook_file"
percontainer_post_hook_file="/tmp/percontainer_posthook"
percontainer_post_hook_command="touch $percontainer_post_hook_file"

if [[ -z $GITHUB_ACTIONS ]]; then
le_container_name="$(basename "${0%/*}")_$(date "+%Y-%m-%d_%H.%M.%S")"
else
le_container_name="$(basename "${0%/*}")"
fi
run_le_container "${1:?}" "$le_container_name" \
--cli-args "--env ACME_PRE_HOOK=$default_pre_hook_command" \
--cli-args "--env ACME_POST_HOOK=$default_post_hook_ommand"

# Create the $domains array from comma separated domains in TEST_DOMAINS.
IFS=',' read -r -a domains <<< "$TEST_DOMAINS"

# Cleanup function with EXIT trap
function cleanup {
# Remove the Nginx container silently.
docker rm --force "${domains[0]}" &> /dev/null
# Cleanup the files created by this run of the test to avoid foiling following test(s).
docker exec "$le_container_name" /app/cleanup_test_artifacts
# Stop the LE container
docker stop "$le_container_name" > /dev/null
}
trap cleanup EXIT

container_email="contact@${domains[0]}"

# Run an nginx container for ${domains[0]} with LETSENCRYPT_EMAIL set.
run_nginx_container --hosts "${domains[0]}" \
--cli-args "--env LETSENCRYPT_EMAIL=${container_email}"

# Run an nginx container for ${domains[1]} with LETSENCRYPT_EMAIL, ACME_PRE_HOOK and ACME_POST_HOOK set.
run_nginx_container --hosts "${domains[1]}" \
--cli-args "--env LETSENCRYPT_EMAIL=${container_email}" \
--cli-args "--env ACME_PRE_HOOK=$percontainer_pre_hook_command" \
--cli-args "--env ACME_POST_HOOK=$percontainer_post_hook_command"

# Wait for a symlink at /etc/nginx/certs/${domains[0]}.crt
wait_for_symlink "${domains[0]}" "$le_container_name"

acme_pre_hook_key="Le_PreHook="
acme_post_hook_key="Le_PostHook="
acme_base64_start="'__ACME_BASE64__START_"
acme_base64_end="__ACME_BASE64__END_'"

# Check if the default command is deliverd properly in /etc/acme.sh
if docker exec "$le_container_name" [[ ! -d "/etc/acme.sh/$container_email" ]]; then
echo "The /etc/acme.sh/$container_email folder does not exist."
elif docker exec "$le_container_name" [[ ! -d "/etc/acme.sh/$container_email/${domains[0]}" ]]; then
echo "The /etc/acme.sh/$container_email/${domains[0]} folder does not exist."
elif docker exec "$le_container_name" [[ ! -f "/etc/acme.sh/$container_email/${domains[0]}/${domains[0]}.conf" ]]; then
echo "The /etc/acme.sh/$container_email/${domains[0]}/${domains[0]}.conf file does not exist."
fi

default_pre_hook_command_base64="${acme_pre_hook_key}${acme_base64_start}$(echo -n "$default_pre_hook_command" | base64)${acme_base64_end}"
default_post_hook_command_base64="${acme_post_hook_key}${acme_base64_start}$(echo -n "$default_post_hook_ommand" | base64)${acme_base64_end}"

default_acme_pre_hook="$(docker exec "$le_container_name" grep "$acme_pre_hook_key" "/etc/acme.sh/$container_email/${domains[0]}/${domains[0]}.conf")"
default_acme_post_hook="$(docker exec "$le_container_name" grep "$acme_post_hook_key" "/etc/acme.sh/$container_email/${domains[0]}/${domains[0]}.conf")"

if [[ "$default_pre_hook_command_base64" != "$default_acme_pre_hook" ]]; then
echo "Default prehook command not saved properly"
fi
if [[ "$default_post_hook_command_base64" != "$default_acme_post_hook" ]]; then
echo "Default posthook command not saved properly"
fi


# Check if the default action is performed
if docker exec "$le_container_name" [[ ! -f "$default_pre_hook_file" ]]; then
echo "Default prehook action failed"
fi
if docker exec "$le_container_name" [[ ! -f "$default_post_hook_file" ]]; then
echo "Default posthook action failed"
fi

# Wait for a symlink at /etc/nginx/certs/${domains[1]}.crt
wait_for_symlink "${domains[1]}" "$le_container_name"

# Check if the per-container command is deliverd properly in /etc/acme.sh
if docker exec "$le_container_name" [[ ! -d "/etc/acme.sh/$container_email/${domains[1]}" ]]; then
echo "The /etc/acme.sh/$container_email/${domains[1]} folder does not exist."
elif docker exec "$le_container_name" [[ ! -f "/etc/acme.sh/$container_email/${domains[1]}/${domains[1]}.conf" ]]; then
echo "The /etc/acme.sh/$container_email/${domains[1]}/${domains[1]}.conf file does not exist."
fi

percontainer_pre_hook_command_base64="${acme_pre_hook_key}${acme_base64_start}$(echo -n "$percontainer_pre_hook_command" | base64)${acme_base64_end}"
percontainer_post_hook_command_base64="${acme_post_hook_key}${acme_base64_start}$(echo -n "$percontainer_post_hook_command" | base64)${acme_base64_end}"

percontainer_acme_pre_hook="$(docker exec "$le_container_name" grep "$acme_pre_hook_key" "/etc/acme.sh/$container_email/${domains[1]}/${domains[1]}.conf")"
percontainer_acme_post_hook="$(docker exec "$le_container_name" grep "$acme_post_hook_key" "/etc/acme.sh/$container_email/${domains[1]}/${domains[1]}.conf")"

if [[ "$percontainer_pre_hook_command_base64" != "$percontainer_acme_pre_hook" ]]; then
echo "Per-container prehook command not saved properly"
fi
if [[ "$percontainer_post_hook_command_base64" != "$percontainer_acme_post_hook" ]]; then
echo "Per-container posthook command not saved properly"
fi


# Check if the percontainer action is performed
if docker exec "$le_container_name" [[ ! -f "$percontainer_pre_hook_file" ]]; then
echo "Per-container prehook action failed"
fi
if docker exec "$le_container_name" [[ ! -f "$percontainer_post_hook_file" ]]; then
echo "Per-container posthook action failed"
fi
36 changes: 28 additions & 8 deletions test/tests/test-functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,30 @@ export -f get_base_domain
function run_le_container {
local image="${1:?}"
local name="${2:?}"
local cli_args_str="${3:-}"
shift 2
local -a cli_args_arr
for arg in $cli_args_str; do
cli_args_arr+=("$arg")

while [[ $# -gt 0 ]]; do
local flag="$1"

case $flag in
-c|--cli-args) #only one value per flag. Multiple args = use flag multiple times
local cli_args_arr_tmp
IFS=' ' read -r -a cli_args_arr_tmp <<< "${2:?}"
cli_args_arr+=("${cli_args_arr_tmp[0]}") #Head
cli_args_arr+=("${cli_args_arr_tmp[*]:1}") #Tail
shift 2
;;

*) #Legacy Option
local cli_args_str="${1:?}"
for arg in $cli_args_str; do
cli_args_arr+=("$arg")
done
shift
;;
esac

done

if [[ "$SETUP" == '3containers' ]]; then
Expand All @@ -30,6 +50,7 @@ function run_le_container {
cli_args_arr+=(--env "ACME_CA_URI=https://pebble:14000/dir")
cli_args_arr+=(--env "CA_BUNDLE=/pebble.minica.pem")
cli_args_arr+=(--network acme_net)
cli_args_arr+=(--volume "${GITHUB_WORKSPACE}/pebble.minica.pem:/pebble.minica.pem")
else
return 1
fi
Expand All @@ -38,7 +59,6 @@ function run_le_container {
--name "$name" \
--volumes-from "$NGINX_CONTAINER_NAME" \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
--volume "${GITHUB_WORKSPACE}/pebble.minica.pem:/pebble.minica.pem" \
"${cli_args_arr[@]}" \
--env "DOCKER_GEN_WAIT=500ms:2s" \
--env "TEST_MODE=true" \
Expand Down Expand Up @@ -75,10 +95,10 @@ function run_nginx_container {
;;

-c|--cli-args)
local cli_args_str="${2:?}"
for arg in $cli_args_str; do
cli_args_arr+=("$arg")
done
local cli_args_arr_tmp
IFS=' ' read -r -a cli_args_arr_tmp <<< "${2:?}"
cli_args_arr+=("${cli_args_arr_tmp[0]}") #Head
cli_args_arr+=("${cli_args_arr_tmp[*]:1}") #Tail
shift 2
;;

Expand Down