Skip to content

Commit 7c41502

Browse files
Add support for custom ACME servers
Allow using a custom/internal ACME server (e.g. step-ca) via CERTPROVIDER=custom and CERTPROVIDERURL=<acme directory url>. - ACMECABUNDLE (file path or base64 PEM) is written to /config/cabundle.pem and trusted via REQUESTS_CA_BUNDLE for both initial issuance and renewals (le-renew.sh). - Optional External Account Binding via EAB_KID/EAB_HMAC_KEY for servers that require it; both must be set or neither. - Custom provider is tracked for change-detection and revocation, so switching servers re-issues against the correct ACME server. Closes #186 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 55a8c4f commit 7c41502

3 files changed

Lines changed: 81 additions & 6 deletions

File tree

readme-vars.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ cap_add_param_vars:
3131
opt_param_usage_include_env: true
3232
opt_param_env_vars:
3333
- {env_var: "SUBDOMAINS", env_value: "www,", desc: "Subdomains you'd like the cert to cover (comma separated, no spaces) ie. `www,ftp,cloud`. For a wildcard cert, set this *exactly* to `wildcard` (wildcard cert is available via `dns` validation only)"}
34-
- {env_var: "CERTPROVIDER", env_value: "", desc: "Optionally define the cert provider. Set to `zerossl` for ZeroSSL certs (requires existing [ZeroSSL account](https://app.zerossl.com/signup) and the e-mail address entered in `EMAIL` env var). Otherwise defaults to Let's Encrypt."}
34+
- {env_var: "CERTPROVIDER", env_value: "", desc: "Optionally define the cert provider. Set to `zerossl` for ZeroSSL certs (requires existing [ZeroSSL account](https://app.zerossl.com/signup) and the e-mail address entered in `EMAIL` env var). Set to `custom` to use a custom/internal ACME server (e.g. [step-ca](https://smallstep.com/docs/step-ca/)), which requires `CERTPROVIDERURL` (see below). Otherwise defaults to Let's Encrypt."}
35+
- {env_var: "CERTPROVIDERURL", env_value: "", desc: "Only used when `CERTPROVIDER=custom`. The ACME directory URL of your custom ACME server, ie. `https://ca.example.com/acme/acme/directory`."}
36+
- {env_var: "ACMECABUNDLE", env_value: "", desc: "Only used when `CERTPROVIDER=custom`. Trust an internal CA so SWAG can connect to your ACME server over TLS. Either a path to a CA bundle PEM file mounted into the container, or the base64-encoded contents of that PEM."}
37+
- {env_var: "EAB_KID", env_value: "", desc: "Only used when `CERTPROVIDER=custom`. External Account Binding key identifier, if your custom ACME server requires EAB. Must be set together with `EAB_HMAC_KEY`."}
38+
- {env_var: "EAB_HMAC_KEY", env_value: "", desc: "Only used when `CERTPROVIDER=custom`. External Account Binding HMAC key, if your custom ACME server requires EAB. Must be set together with `EAB_KID`."}
3539
- {env_var: "CERT_PROFILE", env_value: "", desc: "Optionally define a cert profile to use for cert generation. This is useful if you want to use a custom cert profile instead of the default one. Currently only supported for Let's Encrypt. See https://letsencrypt.org/docs/profiles/ "}
3640
- {env_var: "DNSPLUGIN", env_value: "cloudflare", desc: "Required if `VALIDATION` is set to `dns`. Options are `acmedns`, `aliyun`, `azure`, `bunny`, `cloudflare`, `cpanel`, `desec`, `digitalocean`, `directadmin`, `dnsimple`, `dnsmadeeasy`, `dnspod`, `do`, `domeneshop`, `dreamhost`, `duckdns`, `dynu`, `freedns`, `gandi`, `gehirn`, `glesys`, `godaddy`, `google`, `he`, `hetzner`, `hetzner-cloud`, `infomaniak`, `inwx`, `ionos`, `linode`, `loopia`, `luadns`, `mijn-host`, `namecheap`, `netcup`, `njalla`, `nsone`, `ovh`, `porkbun`, `rfc2136`, `route53`, `sakuracloud`, `standalone`, `transip`, and `vultr`. Also need to enter the credentials into the corresponding ini (or json for some plugins) file under `/config/dns-conf`."}
3741
- {env_var: "PROPAGATION", env_value: "", desc: "Optionally override (in seconds) the default propagation time for the dns plugins."}
@@ -220,6 +224,7 @@ init_diagram: |
220224
"swag:latest" <- Base Images
221225
# changelog
222226
changelogs:
227+
- {date: "11.07.26:", desc: "Add support for custom/internal ACME servers via `CERTPROVIDER=custom` with `CERTPROVIDERURL`, optional `ACMECABUNDLE` for internal CA trust, and optional `EAB_KID`/`EAB_HMAC_KEY` for External Account Binding."}
223228
- {date: "10.07.26:", desc: "Add support for Let's Encrypt cert profiles. Run certbot twice daily with a random delay."}
224229
- {date: "19.06.26:", desc: "Add support for mijn.host dns validation."}
225230
- {date: "01.06.26:", desc: "Remove obsolete old cert check logic."}

root/app/le-renew.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,10 @@ echo "<------------------------------------------------->"
55
echo
66
echo "<------------------------------------------------->"
77
echo "cronjob running on $(date)"
8+
# Trust the custom/internal CA (CERTPROVIDER=custom) when renewing, since
9+
# REQUESTS_CA_BUNDLE is an env var and is not persisted in cli.ini.
10+
if [[ -f /config/cabundle.pem ]]; then
11+
export REQUESTS_CA_BUNDLE="/config/cabundle.pem"
12+
fi
813
echo "Running certbot renew"
914
certbot renew --non-interactive --config-dir /config/etc/letsencrypt --logs-dir /config/log/letsencrypt --work-dir /tmp/letsencrypt --config /config/etc/letsencrypt/cli.ini

root/etc/s6-overlay/s6-rc.d/init-certbot-config/run

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ONLY_SUBDOMAINS=${ONLY_SUBDOMAINS}\\n\
1313
VALIDATION=${VALIDATION}\\n\
1414
CERTPROVIDER=${CERTPROVIDER}\\n\
1515
CERT_PROFILE=${CERT_PROFILE}\\n\
16+
CERTPROVIDERURL=${CERTPROVIDERURL}\\n\
1617
DNSPLUGIN=${DNSPLUGIN}\\n\
1718
EMAIL=${EMAIL}\\n\
1819
STAGING=${STAGING}\\n"
@@ -24,6 +25,12 @@ for i in "${SANED_VARS[@]}"; do
2425
export echo "${i}"="$(echo "${!i}" | tr '[:upper:]' '[:lower:]')"
2526
done
2627

28+
# Custom ACME vars are case-sensitive (URL paths, base64 EAB keys / CA bundle),
29+
# so only strip surrounding quotes; do not lowercase them.
30+
for i in CERTPROVIDERURL ACMECABUNDLE EAB_KID EAB_HMAC_KEY; do
31+
export "${i}=${!i//\"/}"
32+
done
33+
2734
# Check for and install requested DNS plugins
2835
if grep -q "universal-package-install" <<< "${DOCKER_MODS}" && grep -q "certbot-dns" <<< "${INSTALL_PIP_PACKAGES}"; then
2936
echo "**** Installing requested dns plugins ****"
@@ -81,7 +88,7 @@ if [[ -f "/config/donoteditthisfile.conf" ]]; then
8188
mv /config/donoteditthisfile.conf /config/.donoteditthisfile.conf
8289
fi
8390
if [[ ! -f "/config/.donoteditthisfile.conf" ]]; then
84-
echo -e "ORIGURL=\"${URL}\" ORIGSUBDOMAINS=\"${SUBDOMAINS}\" ORIGONLY_SUBDOMAINS=\"${ONLY_SUBDOMAINS}\" ORIGEXTRA_DOMAINS=\"${EXTRA_DOMAINS}\" ORIGVALIDATION=\"${VALIDATION}\" ORIGDNSPLUGIN=\"${DNSPLUGIN}\" ORIGPROPAGATION=\"${PROPAGATION}\" ORIGSTAGING=\"${STAGING}\" ORIGCERTPROVIDER=\"${CERTPROVIDER}\" ORIGEMAIL=\"${EMAIL}\" ORIGCERT_PROFILE=\"${CERT_PROFILE}\"" >/config/.donoteditthisfile.conf
91+
echo -e "ORIGURL=\"${URL}\" ORIGSUBDOMAINS=\"${SUBDOMAINS}\" ORIGONLY_SUBDOMAINS=\"${ONLY_SUBDOMAINS}\" ORIGEXTRA_DOMAINS=\"${EXTRA_DOMAINS}\" ORIGVALIDATION=\"${VALIDATION}\" ORIGDNSPLUGIN=\"${DNSPLUGIN}\" ORIGPROPAGATION=\"${PROPAGATION}\" ORIGSTAGING=\"${STAGING}\" ORIGCERTPROVIDER=\"${CERTPROVIDER}\" ORIGCERTPROVIDERURL=\"${CERTPROVIDERURL}\" ORIGEMAIL=\"${EMAIL}\" ORIGCERT_PROFILE=\"${CERT_PROFILE}\"" >/config/.donoteditthisfile.conf
8592
echo "Created .donoteditthisfile.conf"
8693
fi
8794

@@ -188,15 +195,23 @@ if [[ ! "${URL}" = "${ORIGURL}" ]] ||
188195
[[ ! "${PROPAGATION}" = "${ORIGPROPAGATION}" ]] ||
189196
[[ ! "${STAGING}" = "${ORIGSTAGING}" ]] ||
190197
[[ ! "${CERTPROVIDER}" = "${ORIGCERTPROVIDER}" ]] ||
198+
[[ ! "${CERTPROVIDERURL}" = "${ORIGCERTPROVIDERURL}" ]] ||
191199
[[ ! "${CERT_PROFILE}" = "${ORIGCERT_PROFILE}" ]]; then
192200
echo "Different validation parameters entered than what was used before. Revoking and deleting existing certificate, and an updated one will be created"
193-
if [[ "${ORIGCERTPROVIDER}" = "zerossl" ]]; then
201+
if [[ "${ORIGCERTPROVIDER}" = "custom" ]]; then
202+
REV_ACMESERVER=("${ORIGCERTPROVIDERURL}")
203+
elif [[ "${ORIGCERTPROVIDER}" = "zerossl" ]]; then
194204
REV_ACMESERVER=("https://acme.zerossl.com/v2/DV90")
195205
elif [[ "${ORIGSTAGING}" = "true" ]]; then
196206
REV_ACMESERVER=("https://acme-staging-v02.api.letsencrypt.org/directory")
197207
else
198208
REV_ACMESERVER=("https://acme-v02.api.letsencrypt.org/directory")
199209
fi
210+
# if the previous provider was a custom internal CA, trust its bundle so the
211+
# revocation request can verify TLS
212+
if [[ "${ORIGCERTPROVIDER}" = "custom" ]] && [[ -f /config/cabundle.pem ]]; then
213+
export REQUESTS_CA_BUNDLE="/config/cabundle.pem"
214+
fi
200215
if [[ -f /config/etc/letsencrypt/live/"${ORIGDOMAIN}"/fullchain.pem ]]; then
201216
certbot revoke --config-dir /config/etc/letsencrypt --logs-dir /config/log/letsencrypt --work-dir /tmp/letsencrypt --config /config/etc/letsencrypt/cli.ini --non-interactive --cert-path /config/etc/letsencrypt/live/"${ORIGDOMAIN}"/fullchain.pem --key-path /config/etc/letsencrypt/live/"${ORIGDOMAIN}"/privkey.pem --server "${REV_ACMESERVER[@]}" || true
202217
else
@@ -206,13 +221,34 @@ if [[ ! "${URL}" = "${ORIGURL}" ]] ||
206221
fi
207222

208223
# saving new variables
209-
echo -e "ORIGURL=\"${URL}\" ORIGSUBDOMAINS=\"${SUBDOMAINS}\" ORIGONLY_SUBDOMAINS=\"${ONLY_SUBDOMAINS}\" ORIGEXTRA_DOMAINS=\"${EXTRA_DOMAINS}\" ORIGVALIDATION=\"${VALIDATION}\" ORIGDNSPLUGIN=\"${DNSPLUGIN}\" ORIGPROPAGATION=\"${PROPAGATION}\" ORIGSTAGING=\"${STAGING}\" ORIGCERTPROVIDER=\"${CERTPROVIDER}\" ORIGEMAIL=\"${EMAIL}\" ORIGCERT_PROFILE=\"${CERT_PROFILE}\"" >/config/.donoteditthisfile.conf
224+
echo -e "ORIGURL=\"${URL}\" ORIGSUBDOMAINS=\"${SUBDOMAINS}\" ORIGONLY_SUBDOMAINS=\"${ONLY_SUBDOMAINS}\" ORIGEXTRA_DOMAINS=\"${EXTRA_DOMAINS}\" ORIGVALIDATION=\"${VALIDATION}\" ORIGDNSPLUGIN=\"${DNSPLUGIN}\" ORIGPROPAGATION=\"${PROPAGATION}\" ORIGSTAGING=\"${STAGING}\" ORIGCERTPROVIDER=\"${CERTPROVIDER}\" ORIGCERTPROVIDERURL=\"${CERTPROVIDERURL}\" ORIGEMAIL=\"${EMAIL}\" ORIGCERT_PROFILE=\"${CERT_PROFILE}\"" >/config/.donoteditthisfile.conf
210225

211226
# if zerossl is selected or staging is set to true, use the relevant server
212227
if [[ "${CERTPROVIDER}" = "zerossl" ]] && [[ "${STAGING}" = "true" ]]; then
213228
echo "ZeroSSL does not support staging mode, ignoring STAGING variable"
214229
fi
215-
if [[ "${CERTPROVIDER}" = "zerossl" ]] && [[ -n "${EMAIL}" ]]; then
230+
if [[ "${CERTPROVIDER}" = "custom" ]]; then
231+
if [[ -z "${CERTPROVIDERURL}" ]]; then
232+
echo "CERTPROVIDER is set to custom, but CERTPROVIDERURL is not set. Please set CERTPROVIDERURL to the ACME directory URL of your custom ACME server (e.g. https://ca.example.com/acme/acme/directory). Sleeping."
233+
sleep infinity
234+
fi
235+
echo "Using custom ACME server as the cert provider: ${CERTPROVIDERURL}"
236+
if [[ "${STAGING}" = "true" ]]; then
237+
echo "STAGING has no effect with a custom ACME server, ignoring STAGING variable"
238+
fi
239+
ACMESERVER="${CERTPROVIDERURL}"
240+
# Trust a custom/internal CA so certbot can reach the ACME server over TLS.
241+
# ACMECABUNDLE may be a path to a mounted PEM file or a base64-encoded PEM.
242+
if [[ -n "${ACMECABUNDLE}" ]]; then
243+
if [[ -f "${ACMECABUNDLE}" ]]; then
244+
cp "${ACMECABUNDLE}" /config/cabundle.pem
245+
else
246+
echo "${ACMECABUNDLE}" | base64 -d - >/config/cabundle.pem
247+
fi
248+
lsiown abc:abc /config/cabundle.pem
249+
echo "Custom CA bundle written to /config/cabundle.pem"
250+
fi
251+
elif [[ "${CERTPROVIDER}" = "zerossl" ]] && [[ -n "${EMAIL}" ]]; then
216252
echo "ZeroSSL is selected as the cert provider, registering cert with ${EMAIL}"
217253
ACMESERVER="https://acme.zerossl.com/v2/DV90"
218254
elif [[ "${CERTPROVIDER}" = "zerossl" ]] && [[ -z "${EMAIL}" ]]; then
@@ -244,6 +280,20 @@ else
244280
sed -i "/^preferred-profile\b/d" /config/etc/letsencrypt/cli.ini
245281
fi
246282

283+
# Trust the custom CA for this issuance run; clean up custom remnants when not
284+
# using a custom provider so a previous internal CA / EAB is not reused.
285+
if [[ "${CERTPROVIDER}" = "custom" ]] && [[ -f /config/cabundle.pem ]]; then
286+
export REQUESTS_CA_BUNDLE="/config/cabundle.pem"
287+
elif [[ "${CERTPROVIDER}" != "custom" ]]; then
288+
unset REQUESTS_CA_BUNDLE
289+
rm -f /config/cabundle.pem
290+
if [[ "${ORIGCERTPROVIDER}" = "custom" ]]; then
291+
# drop EAB creds left over from a custom provider so Let's Encrypt /
292+
# ZeroSSL re-register cleanly
293+
sed -i -e "/^eab-kid\b/d" -e "/^eab-hmac-key\b/d" /config/etc/letsencrypt/cli.ini
294+
fi
295+
fi
296+
247297
# figuring out domain only vs domain & subdomains vs subdomains only
248298
DOMAINS_ARRAY=()
249299
if [[ -z "${SUBDOMAINS}" ]] || [[ "${ONLY_SUBDOMAINS}" != true ]]; then
@@ -338,7 +388,22 @@ fi
338388

339389
# generating certs if necessary
340390
if [[ ! -f "/config/keys/letsencrypt/fullchain.pem" ]]; then
341-
if [[ "${CERTPROVIDER}" = "zerossl" ]] && [[ -n "${EMAIL}" ]]; then
391+
if [[ "${CERTPROVIDER}" = "custom" ]]; then
392+
# Optional External Account Binding for custom ACME servers (e.g. step-ca
393+
# ACME provisioners configured to require EAB). Only consumed at account
394+
# registration, so it has no effect on later renewals.
395+
if [[ -n "${EAB_KID}" ]] && [[ -n "${EAB_HMAC_KEY}" ]]; then
396+
echo "Registering with custom ACME server using provided EAB credentials"
397+
set_ini_value "eab-kid" "${EAB_KID}" /config/etc/letsencrypt/cli.ini
398+
set_ini_value "eab-hmac-key" "${EAB_HMAC_KEY}" /config/etc/letsencrypt/cli.ini
399+
elif [[ -n "${EAB_KID}" ]] || [[ -n "${EAB_HMAC_KEY}" ]]; then
400+
echo "Only one of EAB_KID / EAB_HMAC_KEY is set. Both are required to use External Account Binding. Sleeping."
401+
sleep infinity
402+
else
403+
# remove any EAB values left over from a previous provider
404+
sed -i -e "/^eab-kid\b/d" -e "/^eab-hmac-key\b/d" /config/etc/letsencrypt/cli.ini
405+
fi
406+
elif [[ "${CERTPROVIDER}" = "zerossl" ]] && [[ -n "${EMAIL}" ]]; then
342407
echo "Retrieving EAB from ZeroSSL"
343408
EAB_CREDS=$(curl -s https://api.zerossl.com/acme/eab-credentials-email --data "email=${EMAIL}")
344409
ZEROSSL_EAB_KID=$(echo "${EAB_CREDS}" | jq .eab_kid)

0 commit comments

Comments
 (0)