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

feat: DNS-01 challenge support #1137

Merged
merged 13 commits into from
Jul 29, 2024
1 change: 1 addition & 0 deletions .shellcheckrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
external-sources=true
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@ It handles the automated creation, renewal and use of SSL certificates for proxi

### Features:
* Automated creation/renewal of Let's Encrypt (or other ACME CAs) certificates using [**acme.sh**](https://github.com/acmesh-official/acme.sh).
* Let's Encrypt / ACME domain validation through `http-01` challenge only.
* Let's Encrypt / ACME domain validation through `HTTP-01` (by default) or [`DNS-01`](https://github.com/nginx-proxy/acme-companion/blob/main/docs/Let's-Encrypt-and-ACME.md#dns-01-acme-challenge) challenge.
* Automated update and reload of nginx config on certificate creation/renewal.
* Support creation of [Multi-Domain (SAN) Certificates](https://github.com/nginx-proxy/acme-companion/blob/main/docs/Let's-Encrypt-and-ACME.md#multi-domains-certificates).
* Support creation of [Wildcard Certificates](https://community.letsencrypt.org/t/acme-v2-production-environment-wildcards/55578) (with `DNS-01` challenge only).
* Creation of a strong [RFC7919 Diffie-Hellman Group](https://datatracker.ietf.org/doc/html/rfc7919#appendix-A) at startup.
* Work with all versions of docker.

### Requirements:
### HTTP-01 challenge requirements:
* Your host **must** be publicly reachable on **both** port [`80`](https://letsencrypt.org/docs/allow-port-80/) and [`443`](https://github.com/nginx-proxy/acme-companion/discussions/873#discussioncomment-1410225).
* Check your firewall rules and [**do not attempt to block port `80`**](https://letsencrypt.org/docs/allow-port-80/) as that will prevent `http-01` challenges from completing.
* Check your firewall rules and [**do not attempt to block port `80`**](https://letsencrypt.org/docs/allow-port-80/) as that will prevent `HTTP-01` challenges from completing.
* For the same reason, you can't use nginx-proxy's [`HTTPS_METHOD=nohttp`](https://github.com/nginx-proxy/nginx-proxy#how-ssl-support-works).
* The (sub)domains you want to issue certificates for must correctly resolve to the host.
* Your DNS provider must [answer correctly to CAA record requests](https://letsencrypt.org/docs/caa/).
* If your (sub)domains have AAAA records set, the host must be publicly reachable over IPv6 on port `80` and `443`.

If you can't meet these requirements, you can use the `DNS-01` challenge instead. Please refer to the [documentation](./docs/Let's-Encrypt-and-ACME.md#dns-01-acme-challenge) for more information.
buchdag marked this conversation as resolved.
Show resolved Hide resolved

In addition to the above, please ensure that your DNS provider answers correctly to CAA record requests. [If your DNS provider answer with an error, Let's Encrypt won't issue a certificate for your domain](https://letsencrypt.org/docs/caa/). Let's Encrypt do not require that you set a CAA record on your domain, just that your DNS provider answers correctly.

![schema](https://github.com/nginx-proxy/acme-companion/blob/main/schema.png)

## Basic usage (with the nginx-proxy container)
Expand Down
142 changes: 112 additions & 30 deletions app/letsencrypt_service
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ RENEW_PRIVATE_KEYS="$(lc "${RENEW_PRIVATE_KEYS:-true}")"
# Backward compatibility environment variable
REUSE_PRIVATE_KEYS="$(lc "${REUSE_PRIVATE_KEYS:-false}")"

function strip_wildcard {
# Remove wildcard prefix if present
# https://github.com/nginx-proxy/nginx-proxy/tree/main/docs#wildcard-certificates
local -r domain="${1?missing domain argument}"
if [[ "${domain:0:2}" == "*." ]]; then
echo "${domain:2}"
else
echo "$domain"
fi
}

function create_link {
local -r source=${1?missing source argument}
local -r target=${2?missing target argument}
Expand All @@ -27,7 +38,8 @@ function create_link {

function create_links {
local -r base_domain=${1?missing base_domain argument}
local -r domain=${2?missing base_domain argument}
local domain=${2?missing base_domain argument}
domain="$(strip_wildcard "$domain")"

if [[ ! -f "/etc/nginx/certs/$base_domain/fullchain.pem" || \
! -f "/etc/nginx/certs/$base_domain/key.pem" ]]; then
Expand Down Expand Up @@ -75,6 +87,7 @@ function cleanup_links {
for cid in "${LETSENCRYPT_CONTAINERS[@]}"; do
local -n hosts_array="LETSENCRYPT_${cid}_HOST"
for domain in "${hosts_array[@]}"; do
domain="$(strip_wildcard "$domain")"
# Add domain to the array storing currently enabled domains.
ENABLED_DOMAINS+=("$domain")
done
Expand Down Expand Up @@ -128,6 +141,11 @@ function update_cert {
# First domain will be our base domain
local base_domain="${hosts_array[0]}"

local wildcard_certificate='false'
if [[ "${base_domain:0:2}" == "*." ]]; then
wildcard_certificate='true'
fi

local should_restart_container='false'

# Base CLI parameters array, used for both --register-account and --issue
Expand All @@ -151,11 +169,69 @@ function update_cert {

# CLI parameters array used for --issue
local -a params_issue_arr
params_issue_arr+=(--webroot /usr/share/nginx/html)

# ACME challenge type
local -n acme_challenge="ACME_${cid}_CHALLENGE"
if [[ -z "${acme_challenge}" ]]; then
acme_challenge="${ACME_CHALLENGE:-HTTP-01}"
fi

if [[ "$acme_challenge" == "HTTP-01" ]]; then
# HTTP-01 challenge
if [[ "$wildcard_certificate" == 'true' ]]; then
echo "Error: wildcard certificates (${base_domain}) can't be obtained with HTTP-01 challenge"
return 1
fi
params_issue_arr+=(--webroot /usr/share/nginx/html)
elif [[ "$acme_challenge" == "DNS-01" ]]; then
# DNS-01 challenge
local acmesh_dns_config_used='none'

local default_acmesh_dns_api="${DEFAULT_ACMESH_DNS_API_CONFIG[DNS_API]}"
[[ -n "$default_acmesh_dns_api" ]] && acmesh_dns_config_used='default'

local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG"
local acmesh_dns_api="${acmesh_dns_config[DNS_API]}"
[[ -n "$acmesh_dns_api" ]] && acmesh_dns_config_used='container'

local -a dns_api_keys

case "$acmesh_dns_config_used" in
'default')
params_issue_arr+=(--dns "$default_acmesh_dns_api")
# Loop over defined variable for default acme.sh DNS api config
for key in "${!DEFAULT_ACMESH_DNS_API_CONFIG[@]}"; do
[[ "$key" == "DNS_API" ]] && continue
dns_api_keys+=("$key")
local value="${DEFAULT_ACMESH_DNS_API_CONFIG[$key]}"
local -x "$key"="$value"
done
;;
'container')
params_issue_arr+=(--dns "$acmesh_dns_api")
# Loop over defined variable for per container acme.sh DNS api config
for key in "${!acmesh_dns_config[@]}"; do
[[ "$key" == "DNS_API" ]] && continue
dns_api_keys+=("$key")
local value="${acmesh_dns_config[$key]}"
local -x "$key"="$value"
done
;;
*)
echo "Error: missing acme.sh DNS API for DNS challenge"
return 1
;;
esac

echo "Info: DNS challenge using $acmesh_dns_api DNS API with the following keys: ${dns_api_keys[*]} (${acmesh_dns_config_used} config)"
else
echo "Error: unknown ACME challenge method: $acme_challenge"
return 1
fi

local -n cert_keysize="LETSENCRYPT_${cid}_KEYSIZE"
if [[ -z "$cert_keysize" ]] || \
[[ ! "$cert_keysize" =~ ^(2048|3072|4096|ec-256|ec-384)$ ]]; then
[[ ! "$cert_keysize" =~ ^('2048'|'3072'|'4096'|'ec-256'|'ec-384')$ ]]; then
cert_keysize=$DEFAULT_KEY_SIZE
fi
params_issue_arr+=(--keylength "$cert_keysize")
Expand Down Expand Up @@ -206,23 +282,28 @@ function update_cert {
local ca_path_dir
ca_path_dir="$(echo "$acme_ca_uri" | cut -d : -f 2- | tr -s / | cut -d / -f 3-)"

local certificate_dir
local relative_certificate_dir
if [[ "$wildcard_certificate" == 'true' ]]; then
relative_certificate_dir="wildcard_${base_domain:2}"
else
relative_certificate_dir="$base_domain"
fi
# If we're going to use one of LE stating endpoints ...
if [[ "$acme_ca_uri" =~ ^https://acme-staging.* ]]; then
# Unset accountemail
# force config dir to 'staging'
unset accountemail
config_home="/etc/acme.sh/staging"
# Prefix test certificate directory with _test_
certificate_dir="/etc/nginx/certs/_test_$base_domain"
else
certificate_dir="/etc/nginx/certs/$base_domain"
relative_certificate_dir="_test_${relative_certificate_dir}"
fi

local absolute_certificate_dir="/etc/nginx/certs/$relative_certificate_dir"
params_issue_arr+=( \
--cert-file "${certificate_dir}/cert.pem" \
--key-file "${certificate_dir}/key.pem" \
--ca-file "${certificate_dir}/chain.pem" \
--fullchain-file "${certificate_dir}/fullchain.pem" \
--cert-file "${absolute_certificate_dir}/cert.pem" \
--key-file "${absolute_certificate_dir}/key.pem" \
--ca-file "${absolute_certificate_dir}/chain.pem" \
--fullchain-file "${absolute_certificate_dir}/fullchain.pem" \
)

[[ ! -d "$config_home" ]] && mkdir -p "$config_home"
Expand Down Expand Up @@ -342,14 +423,14 @@ function update_cert {
[[ "${2:-}" == "--force-renew" ]] && params_issue_arr+=(--force)

# Create directory for the first domain
mkdir -p "$certificate_dir"
set_ownership_and_permissions "$certificate_dir"
mkdir -p "$absolute_certificate_dir"
set_ownership_and_permissions "$absolute_certificate_dir"

for domain in "${hosts_array[@]}"; do
# Add all the domains to certificate
params_issue_arr+=(--domain "$domain")
# If enabled, add location configuration for the domain
if parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then
if [[ "$acme_challenge" == "HTTP-01" ]] && parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then
add_location_configuration "$domain" || reload_nginx
fi
done
Expand All @@ -364,24 +445,19 @@ function update_cert {
# 0 = success, 2 = RENEW_SKIP
if [[ $acmesh_return == 0 || $acmesh_return == 2 ]]; then
for domain in "${hosts_array[@]}"; do
if [[ $acme_ca_uri =~ ^https://acme-staging.* ]]; then
create_links "_test_$base_domain" "$domain" \
&& should_reload_nginx='true' \
&& should_restart_container='true'
else
create_links "$base_domain" "$domain" \
&& should_reload_nginx='true' \
&& should_restart_container='true'
fi
create_links "$relative_certificate_dir" "$domain" \
&& should_reload_nginx='true' \
&& should_restart_container='true'
done
echo "${COMPANION_VERSION:-}" > "${certificate_dir}/.companion"
set_ownership_and_permissions "${certificate_dir}/.companion"
echo "${COMPANION_VERSION:-}" > "${absolute_certificate_dir}/.companion"
set_ownership_and_permissions "${absolute_certificate_dir}/.companion"
# Make private key root readable only
for file in cert.pem key.pem chain.pem fullchain.pem; do
local file_path="${certificate_dir}/${file}"
local file_path="${absolute_certificate_dir}/${file}"
[[ -e "$file_path" ]] && set_ownership_and_permissions "$file_path"
done
local acme_private_key="$(find /etc/acme.sh -name "*.key" -path "*${hosts_array[0]}*")"
local acme_private_key
acme_private_key="$(find /etc/acme.sh -name "*.key" -path "*${hosts_array[0]}*")"
[[ -e "$acme_private_key" ]] && set_ownership_and_permissions "$acme_private_key"
# Queue nginx reload if a certificate was issued or renewed
[[ $acmesh_return -eq 0 ]] \
Expand Down Expand Up @@ -424,9 +500,15 @@ function update_certs {
if source /app/letsencrypt_user_data; then
for cid in "${LETSENCRYPT_STANDALONE_CERTS[@]}"; do
local -n hosts_array="LETSENCRYPT_${cid}_HOST"
for domain in "${hosts_array[@]}"; do
add_standalone_configuration "$domain"
done

local -n acme_challenge="ACME_${cid}_CHALLENGE"
acme_challenge="${acme_challenge:-HTTP-01}"

if [[ "$acme_challenge" == "HTTP-01" ]]; then
for domain in "${hosts_array[@]}"; do
add_standalone_configuration "$domain"
done
fi
done
reload_nginx
LETSENCRYPT_CONTAINERS+=( "${LETSENCRYPT_STANDALONE_CERTS[@]}" )
Expand Down
40 changes: 34 additions & 6 deletions app/letsencrypt_service_data.tmpl
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
#!/bin/bash
# shellcheck disable=SC2034
{{- $DEFAULT_ACMESH_DNS_API_CONFIG := fromYaml (coalesce $.Env.ACMESH_DNS_API_CONFIG "") }}
{{- if $DEFAULT_ACMESH_DNS_API_CONFIG }}
{{- "\n" }}declare -A DEFAULT_ACMESH_DNS_API_CONFIG=(
{{- range $key, $value := $DEFAULT_ACMESH_DNS_API_CONFIG }}
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
{{- end }}
{{- "\n" }})
{{- end }}


LETSENCRYPT_CONTAINERS=(
{{ $orderedContainers := sortObjectsByKeysDesc $ "Created" }}
{{ range $_, $container := whereExist $orderedContainers "Env.LETSENCRYPT_HOST" }}
Expand All @@ -8,11 +18,11 @@ LETSENCRYPT_CONTAINERS=(
{{/* Explicit per-domain splitting of the certificate */}}
{{ range $host := split $container.Env.LETSENCRYPT_HOST "," }}
{{ $host := trim $host }}
{{- "\n " }}'{{ printf "%.12s" $container.ID }}_{{ sha1 $host }}' # {{ $container.Name }}, created at {{ $container.Created }}
{{- "\n\t" }}'{{ printf "%.12s" $container.ID }}_{{ sha1 $host }}' # {{ $container.Name }}, created at {{ $container.Created }}
{{ end }}
{{ else }}
{{/* Default: multi-domain (SAN) certificate */}}
{{- "\n " }}'{{ printf "%.12s" $container.ID }}' # {{ $container.Name }}, created at {{ $container.Created }}
{{- "\n\t" }}'{{ printf "%.12s" $container.ID }}' # {{ $container.Name }}, created at {{ $container.Created }}
{{ end }}
{{ end }}
{{ end }}
Expand All @@ -26,6 +36,8 @@ LETSENCRYPT_CONTAINERS=(
{{ $STAGING := trim (coalesce $container.Env.LETSENCRYPT_TEST "") }}
{{ $EMAIL := trim (coalesce $container.Env.LETSENCRYPT_EMAIL "") }}
{{ $CA_URI := trim (coalesce $container.Env.ACME_CA_URI "") }}
{{ $ACME_CHALLENGE := trim (coalesce $container.Env.ACME_CHALLENGE "") }}
{{ $ACMESH_DNS_API_CONFIG := fromYaml (coalesce $container.Env.ACMESH_DNS_API_CONFIG "") }}
{{ $PREFERRED_CHAIN := trim (coalesce $container.Env.ACME_PREFERRED_CHAIN "") }}
{{ $OCSP := trim (coalesce $container.Env.ACME_OCSP "") }}
{{ $EAB_KID := trim (coalesce $container.Env.ACME_EAB_KID "") }}
Expand All @@ -47,6 +59,14 @@ LETSENCRYPT_CONTAINERS=(
{{- "\n" }}LETSENCRYPT_{{ $cid }}_{{ $hostHash }}_TEST="{{ $STAGING }}"
{{- "\n" }}LETSENCRYPT_{{ $cid }}_{{ $hostHash }}_EMAIL="{{ $EMAIL }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_CA_URI="{{ $CA_URI }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_CHALLENGE="{{ $ACME_CHALLENGE }}"
{{- if $ACMESH_DNS_API_CONFIG }}
{{- "\n" }}declare -A ACMESH_{{ $cid }}_{{ $hostHash }}_DNS_API_CONFIG=(
{{- range $key, $value := $ACMESH_DNS_API_CONFIG }}
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
{{- end }}
{{- "\n" }})
{{- end }}
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_PREFERRED_CHAIN="{{ $PREFERRED_CHAIN }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_OCSP="{{ $OCSP }}"
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_EAB_KID="{{ $EAB_KID }}"
Expand All @@ -61,14 +81,22 @@ LETSENCRYPT_CONTAINERS=(
{{- "\n" }}LETSENCRYPT_{{ $cid }}_HOST=(
{{- range $host := split $hosts "," }}
{{- $host := trim $host }}
{{- $host := trimSuffix "." $host -}}
'{{ $host }}'{{ " " }}
{{- end -}}
)
{{- $host := trimSuffix "." $host }}
{{- "\n\t" }}'{{ $host }}'
{{- end }}
{{- "\n" }})
{{- "\n" }}LETSENCRYPT_{{ $cid }}_KEYSIZE="{{ $KEYSIZE }}"
{{- "\n" }}LETSENCRYPT_{{ $cid }}_TEST="{{ $STAGING }}"
{{- "\n" }}LETSENCRYPT_{{ $cid }}_EMAIL="{{ $EMAIL }}"
{{- "\n" }}ACME_{{ $cid }}_CA_URI="{{ $CA_URI }}"
{{- "\n" }}ACME_{{ $cid }}_CHALLENGE="{{ $ACME_CHALLENGE }}"
{{- if $ACMESH_DNS_API_CONFIG }}
{{- "\n" }}declare -A ACMESH_{{ $cid }}_DNS_API_CONFIG=(
{{- range $key, $value := $ACMESH_DNS_API_CONFIG }}
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
{{- end }}
{{- "\n" }})
{{- end }}
{{- "\n" }}ACME_{{ $cid }}_PREFERRED_CHAIN="{{ $PREFERRED_CHAIN }}"
{{- "\n" }}ACME_{{ $cid }}_OCSP="{{ $OCSP }}"
{{- "\n" }}ACME_{{ $cid }}_EAB_KID="{{ $EAB_KID }}"
Expand Down
4 changes: 3 additions & 1 deletion app/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ term_handler() {

# shellcheck source=functions.sh
source /app/functions.sh
remove_all_location_configurations
if parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then
remove_all_location_configurations
fi
buchdag marked this conversation as resolved.
Show resolved Hide resolved
remove_all_standalone_configurations

exit 0
Expand Down
2 changes: 1 addition & 1 deletion docs/Basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Two writable volumes must be declared on the **nginx-proxy** container so that they can be shared with the **acme-companion** container:

* `/etc/nginx/certs` to store certificates and private keys (readonly for the **nginx-proxy** container).
* `/usr/share/nginx/html` to write `http-01` challenge files.
* `/usr/share/nginx/html` to write `HTTP-01` challenge files.

Additionally, a fourth volume must be declared on the **acme-companion** container to store `acme.sh` configuration and state: `/etc/acme.sh`.

Expand Down
Loading