Skip to content

Commit 126f1ee

Browse files
authored
Merge pull request #1137 from nginx-proxy/dns-challenge
feat: DNS-01 challenge support
2 parents c2764aa + b048f4e commit 126f1ee

6 files changed

+208
-41
lines changed

.shellcheckrc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
external-sources=true

README.md

+8-4
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,24 @@ It handles the automated creation, renewal and use of SSL certificates for proxi
1010

1111
### Features:
1212
* Automated creation/renewal of Let's Encrypt (or other ACME CAs) certificates using [**acme.sh**](https://github.com/acmesh-official/acme.sh).
13-
* Let's Encrypt / ACME domain validation through `http-01` challenge only.
13+
* 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.
1414
* Automated update and reload of nginx config on certificate creation/renewal.
1515
* 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).
16+
* Support creation of [Wildcard Certificates](https://community.letsencrypt.org/t/acme-v2-production-environment-wildcards/55578) (with `DNS-01` challenge only).
1617
* Creation of a strong [RFC7919 Diffie-Hellman Group](https://datatracker.ietf.org/doc/html/rfc7919#appendix-A) at startup.
1718
* Work with all versions of docker.
1819

19-
### Requirements:
20+
### HTTP-01 challenge requirements:
2021
* 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).
21-
* 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.
22+
* 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.
2223
* 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).
2324
* The (sub)domains you want to issue certificates for must correctly resolve to the host.
24-
* Your DNS provider must [answer correctly to CAA record requests](https://letsencrypt.org/docs/caa/).
2525
* If your (sub)domains have AAAA records set, the host must be publicly reachable over IPv6 on port `80` and `443`.
2626

27+
If you can't meet these requirements, you can use the `DNS-01` challenge instead. Please refer to the [documentation](https://github.com/nginx-proxy/acme-companion/blob/main/docs/Let's-Encrypt-and-ACME.md#dns-01-acme-challenge) for more information.
28+
29+
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.
30+
2731
![schema](https://github.com/nginx-proxy/acme-companion/blob/main/schema.png)
2832

2933
## Basic usage (with the nginx-proxy container)

app/letsencrypt_service

+112-30
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ RENEW_PRIVATE_KEYS="$(lc "${RENEW_PRIVATE_KEYS:-true}")"
1212
# Backward compatibility environment variable
1313
REUSE_PRIVATE_KEYS="$(lc "${REUSE_PRIVATE_KEYS:-false}")"
1414

15+
function strip_wildcard {
16+
# Remove wildcard prefix if present
17+
# https://github.com/nginx-proxy/nginx-proxy/tree/main/docs#wildcard-certificates
18+
local -r domain="${1?missing domain argument}"
19+
if [[ "${domain:0:2}" == "*." ]]; then
20+
echo "${domain:2}"
21+
else
22+
echo "$domain"
23+
fi
24+
}
25+
1526
function create_link {
1627
local -r source=${1?missing source argument}
1728
local -r target=${2?missing target argument}
@@ -27,7 +38,8 @@ function create_link {
2738

2839
function create_links {
2940
local -r base_domain=${1?missing base_domain argument}
30-
local -r domain=${2?missing base_domain argument}
41+
local domain=${2?missing base_domain argument}
42+
domain="$(strip_wildcard "$domain")"
3143

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

144+
local wildcard_certificate='false'
145+
if [[ "${base_domain:0:2}" == "*." ]]; then
146+
wildcard_certificate='true'
147+
fi
148+
131149
local should_restart_container='false'
132150

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

152170
# CLI parameters array used for --issue
153171
local -a params_issue_arr
154-
params_issue_arr+=(--webroot /usr/share/nginx/html)
172+
173+
# ACME challenge type
174+
local -n acme_challenge="ACME_${cid}_CHALLENGE"
175+
if [[ -z "${acme_challenge}" ]]; then
176+
acme_challenge="${ACME_CHALLENGE:-HTTP-01}"
177+
fi
178+
179+
if [[ "$acme_challenge" == "HTTP-01" ]]; then
180+
# HTTP-01 challenge
181+
if [[ "$wildcard_certificate" == 'true' ]]; then
182+
echo "Error: wildcard certificates (${base_domain}) can't be obtained with HTTP-01 challenge"
183+
return 1
184+
fi
185+
params_issue_arr+=(--webroot /usr/share/nginx/html)
186+
elif [[ "$acme_challenge" == "DNS-01" ]]; then
187+
# DNS-01 challenge
188+
local acmesh_dns_config_used='none'
189+
190+
local default_acmesh_dns_api="${DEFAULT_ACMESH_DNS_API_CONFIG[DNS_API]}"
191+
[[ -n "$default_acmesh_dns_api" ]] && acmesh_dns_config_used='default'
192+
193+
local -n acmesh_dns_config="ACMESH_${cid}_DNS_API_CONFIG"
194+
local acmesh_dns_api="${acmesh_dns_config[DNS_API]}"
195+
[[ -n "$acmesh_dns_api" ]] && acmesh_dns_config_used='container'
196+
197+
local -a dns_api_keys
198+
199+
case "$acmesh_dns_config_used" in
200+
'default')
201+
params_issue_arr+=(--dns "$default_acmesh_dns_api")
202+
# Loop over defined variable for default acme.sh DNS api config
203+
for key in "${!DEFAULT_ACMESH_DNS_API_CONFIG[@]}"; do
204+
[[ "$key" == "DNS_API" ]] && continue
205+
dns_api_keys+=("$key")
206+
local value="${DEFAULT_ACMESH_DNS_API_CONFIG[$key]}"
207+
local -x "$key"="$value"
208+
done
209+
;;
210+
'container')
211+
params_issue_arr+=(--dns "$acmesh_dns_api")
212+
# Loop over defined variable for per container acme.sh DNS api config
213+
for key in "${!acmesh_dns_config[@]}"; do
214+
[[ "$key" == "DNS_API" ]] && continue
215+
dns_api_keys+=("$key")
216+
local value="${acmesh_dns_config[$key]}"
217+
local -x "$key"="$value"
218+
done
219+
;;
220+
*)
221+
echo "Error: missing acme.sh DNS API for DNS challenge"
222+
return 1
223+
;;
224+
esac
225+
226+
echo "Info: DNS challenge using $acmesh_dns_api DNS API with the following keys: ${dns_api_keys[*]} (${acmesh_dns_config_used} config)"
227+
else
228+
echo "Error: unknown ACME challenge method: $acme_challenge"
229+
return 1
230+
fi
155231

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

209-
local certificate_dir
285+
local relative_certificate_dir
286+
if [[ "$wildcard_certificate" == 'true' ]]; then
287+
relative_certificate_dir="wildcard_${base_domain:2}"
288+
else
289+
relative_certificate_dir="$base_domain"
290+
fi
210291
# If we're going to use one of LE stating endpoints ...
211292
if [[ "$acme_ca_uri" =~ ^https://acme-staging.* ]]; then
212293
# Unset accountemail
213294
# force config dir to 'staging'
214295
unset accountemail
215296
config_home="/etc/acme.sh/staging"
216297
# Prefix test certificate directory with _test_
217-
certificate_dir="/etc/nginx/certs/_test_$base_domain"
218-
else
219-
certificate_dir="/etc/nginx/certs/$base_domain"
298+
relative_certificate_dir="_test_${relative_certificate_dir}"
220299
fi
300+
301+
local absolute_certificate_dir="/etc/nginx/certs/$relative_certificate_dir"
221302
params_issue_arr+=( \
222-
--cert-file "${certificate_dir}/cert.pem" \
223-
--key-file "${certificate_dir}/key.pem" \
224-
--ca-file "${certificate_dir}/chain.pem" \
225-
--fullchain-file "${certificate_dir}/fullchain.pem" \
303+
--cert-file "${absolute_certificate_dir}/cert.pem" \
304+
--key-file "${absolute_certificate_dir}/key.pem" \
305+
--ca-file "${absolute_certificate_dir}/chain.pem" \
306+
--fullchain-file "${absolute_certificate_dir}/fullchain.pem" \
226307
)
227308

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

344425
# Create directory for the first domain
345-
mkdir -p "$certificate_dir"
346-
set_ownership_and_permissions "$certificate_dir"
426+
mkdir -p "$absolute_certificate_dir"
427+
set_ownership_and_permissions "$absolute_certificate_dir"
347428

348429
for domain in "${hosts_array[@]}"; do
349430
# Add all the domains to certificate
350431
params_issue_arr+=(--domain "$domain")
351432
# If enabled, add location configuration for the domain
352-
if parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then
433+
if [[ "$acme_challenge" == "HTTP-01" ]] && parse_true "${ACME_HTTP_CHALLENGE_LOCATION:=false}"; then
353434
add_location_configuration "$domain" || reload_nginx
354435
fi
355436
done
@@ -364,24 +445,19 @@ function update_cert {
364445
# 0 = success, 2 = RENEW_SKIP
365446
if [[ $acmesh_return == 0 || $acmesh_return == 2 ]]; then
366447
for domain in "${hosts_array[@]}"; do
367-
if [[ $acme_ca_uri =~ ^https://acme-staging.* ]]; then
368-
create_links "_test_$base_domain" "$domain" \
369-
&& should_reload_nginx='true' \
370-
&& should_restart_container='true'
371-
else
372-
create_links "$base_domain" "$domain" \
373-
&& should_reload_nginx='true' \
374-
&& should_restart_container='true'
375-
fi
448+
create_links "$relative_certificate_dir" "$domain" \
449+
&& should_reload_nginx='true' \
450+
&& should_restart_container='true'
376451
done
377-
echo "${COMPANION_VERSION:-}" > "${certificate_dir}/.companion"
378-
set_ownership_and_permissions "${certificate_dir}/.companion"
452+
echo "${COMPANION_VERSION:-}" > "${absolute_certificate_dir}/.companion"
453+
set_ownership_and_permissions "${absolute_certificate_dir}/.companion"
379454
# Make private key root readable only
380455
for file in cert.pem key.pem chain.pem fullchain.pem; do
381-
local file_path="${certificate_dir}/${file}"
456+
local file_path="${absolute_certificate_dir}/${file}"
382457
[[ -e "$file_path" ]] && set_ownership_and_permissions "$file_path"
383458
done
384-
local acme_private_key="$(find /etc/acme.sh -name "*.key" -path "*${hosts_array[0]}*")"
459+
local acme_private_key
460+
acme_private_key="$(find /etc/acme.sh -name "*.key" -path "*${hosts_array[0]}*")"
385461
[[ -e "$acme_private_key" ]] && set_ownership_and_permissions "$acme_private_key"
386462
# Queue nginx reload if a certificate was issued or renewed
387463
[[ $acmesh_return -eq 0 ]] \
@@ -424,9 +500,15 @@ function update_certs {
424500
if source /app/letsencrypt_user_data; then
425501
for cid in "${LETSENCRYPT_STANDALONE_CERTS[@]}"; do
426502
local -n hosts_array="LETSENCRYPT_${cid}_HOST"
427-
for domain in "${hosts_array[@]}"; do
428-
add_standalone_configuration "$domain"
429-
done
503+
504+
local -n acme_challenge="ACME_${cid}_CHALLENGE"
505+
acme_challenge="${acme_challenge:-HTTP-01}"
506+
507+
if [[ "$acme_challenge" == "HTTP-01" ]]; then
508+
for domain in "${hosts_array[@]}"; do
509+
add_standalone_configuration "$domain"
510+
done
511+
fi
430512
done
431513
reload_nginx
432514
LETSENCRYPT_CONTAINERS+=( "${LETSENCRYPT_STANDALONE_CERTS[@]}" )

app/letsencrypt_service_data.tmpl

+34-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
#!/bin/bash
22
# shellcheck disable=SC2034
3+
{{- $DEFAULT_ACMESH_DNS_API_CONFIG := fromYaml (coalesce $.Env.ACMESH_DNS_API_CONFIG "") }}
4+
{{- if $DEFAULT_ACMESH_DNS_API_CONFIG }}
5+
{{- "\n" }}declare -A DEFAULT_ACMESH_DNS_API_CONFIG=(
6+
{{- range $key, $value := $DEFAULT_ACMESH_DNS_API_CONFIG }}
7+
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
8+
{{- end }}
9+
{{- "\n" }})
10+
{{- end }}
11+
12+
313
LETSENCRYPT_CONTAINERS=(
414
{{ $orderedContainers := sortObjectsByKeysDesc $ "Created" }}
515
{{ range $_, $container := whereExist $orderedContainers "Env.LETSENCRYPT_HOST" }}
@@ -8,11 +18,11 @@ LETSENCRYPT_CONTAINERS=(
818
{{/* Explicit per-domain splitting of the certificate */}}
919
{{ range $host := split $container.Env.LETSENCRYPT_HOST "," }}
1020
{{ $host := trim $host }}
11-
{{- "\n " }}'{{ printf "%.12s" $container.ID }}_{{ sha1 $host }}' # {{ $container.Name }}, created at {{ $container.Created }}
21+
{{- "\n\t" }}'{{ printf "%.12s" $container.ID }}_{{ sha1 $host }}' # {{ $container.Name }}, created at {{ $container.Created }}
1222
{{ end }}
1323
{{ else }}
1424
{{/* Default: multi-domain (SAN) certificate */}}
15-
{{- "\n " }}'{{ printf "%.12s" $container.ID }}' # {{ $container.Name }}, created at {{ $container.Created }}
25+
{{- "\n\t" }}'{{ printf "%.12s" $container.ID }}' # {{ $container.Name }}, created at {{ $container.Created }}
1626
{{ end }}
1727
{{ end }}
1828
{{ end }}
@@ -26,6 +36,8 @@ LETSENCRYPT_CONTAINERS=(
2636
{{ $STAGING := trim (coalesce $container.Env.LETSENCRYPT_TEST "") }}
2737
{{ $EMAIL := trim (coalesce $container.Env.LETSENCRYPT_EMAIL "") }}
2838
{{ $CA_URI := trim (coalesce $container.Env.ACME_CA_URI "") }}
39+
{{ $ACME_CHALLENGE := trim (coalesce $container.Env.ACME_CHALLENGE "") }}
40+
{{ $ACMESH_DNS_API_CONFIG := fromYaml (coalesce $container.Env.ACMESH_DNS_API_CONFIG "") }}
2941
{{ $PREFERRED_CHAIN := trim (coalesce $container.Env.ACME_PREFERRED_CHAIN "") }}
3042
{{ $OCSP := trim (coalesce $container.Env.ACME_OCSP "") }}
3143
{{ $EAB_KID := trim (coalesce $container.Env.ACME_EAB_KID "") }}
@@ -47,6 +59,14 @@ LETSENCRYPT_CONTAINERS=(
4759
{{- "\n" }}LETSENCRYPT_{{ $cid }}_{{ $hostHash }}_TEST="{{ $STAGING }}"
4860
{{- "\n" }}LETSENCRYPT_{{ $cid }}_{{ $hostHash }}_EMAIL="{{ $EMAIL }}"
4961
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_CA_URI="{{ $CA_URI }}"
62+
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_CHALLENGE="{{ $ACME_CHALLENGE }}"
63+
{{- if $ACMESH_DNS_API_CONFIG }}
64+
{{- "\n" }}declare -A ACMESH_{{ $cid }}_{{ $hostHash }}_DNS_API_CONFIG=(
65+
{{- range $key, $value := $ACMESH_DNS_API_CONFIG }}
66+
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
67+
{{- end }}
68+
{{- "\n" }})
69+
{{- end }}
5070
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_PREFERRED_CHAIN="{{ $PREFERRED_CHAIN }}"
5171
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_OCSP="{{ $OCSP }}"
5272
{{- "\n" }}ACME_{{ $cid }}_{{ $hostHash }}_EAB_KID="{{ $EAB_KID }}"
@@ -61,14 +81,22 @@ LETSENCRYPT_CONTAINERS=(
6181
{{- "\n" }}LETSENCRYPT_{{ $cid }}_HOST=(
6282
{{- range $host := split $hosts "," }}
6383
{{- $host := trim $host }}
64-
{{- $host := trimSuffix "." $host -}}
65-
'{{ $host }}'{{ " " }}
66-
{{- end -}}
67-
)
84+
{{- $host := trimSuffix "." $host }}
85+
{{- "\n\t" }}'{{ $host }}'
86+
{{- end }}
87+
{{- "\n" }})
6888
{{- "\n" }}LETSENCRYPT_{{ $cid }}_KEYSIZE="{{ $KEYSIZE }}"
6989
{{- "\n" }}LETSENCRYPT_{{ $cid }}_TEST="{{ $STAGING }}"
7090
{{- "\n" }}LETSENCRYPT_{{ $cid }}_EMAIL="{{ $EMAIL }}"
7191
{{- "\n" }}ACME_{{ $cid }}_CA_URI="{{ $CA_URI }}"
92+
{{- "\n" }}ACME_{{ $cid }}_CHALLENGE="{{ $ACME_CHALLENGE }}"
93+
{{- if $ACMESH_DNS_API_CONFIG }}
94+
{{- "\n" }}declare -A ACMESH_{{ $cid }}_DNS_API_CONFIG=(
95+
{{- range $key, $value := $ACMESH_DNS_API_CONFIG }}
96+
{{- "\n\t" }}['{{ $key }}']='{{ $value }}'
97+
{{- end }}
98+
{{- "\n" }})
99+
{{- end }}
72100
{{- "\n" }}ACME_{{ $cid }}_PREFERRED_CHAIN="{{ $PREFERRED_CHAIN }}"
73101
{{- "\n" }}ACME_{{ $cid }}_OCSP="{{ $OCSP }}"
74102
{{- "\n" }}ACME_{{ $cid }}_EAB_KID="{{ $EAB_KID }}"

docs/Basic-usage.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Two writable volumes must be declared on the **nginx-proxy** container so that they can be shared with the **acme-companion** container:
44

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

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

0 commit comments

Comments
 (0)