Add support for extracting certs from Caddy v2 data#3485
Add support for extracting certs from Caddy v2 data#3485am97 wants to merge 2 commits intodocker-mailserver:masterfrom
Conversation
Ensures we can retrieve the certificates even if the domain has a wildcard or other characters
|
I would like to add a test for the new |
|
Documentation preview for this PR is ready! 🎉 Built with commit: 55afb92 |
polarathene
left a comment
There was a problem hiding this comment.
Hey thanks for the contribution! ❤️
There's a fair amount of changes to address, usually it's a better idea to open an issue and discuss them prior for approval.
If you can address the change requests I'm ok with continuing the review to approval, but keep in mind that a refactor in future may affect this feature.
| EXTRACTED_DOMAIN=('DOMAINNAME' "${DOMAINNAME}") | ||
| else | ||
| _log 'warn' "letsencrypt (acme.json) failed to identify a certificate to extract" | ||
| exit 1 |
There was a problem hiding this comment.
I don't think we like to use exit as it'll stop DMS (during startup, or the check-for-changes.sh service), the log is only warn. If we really do want to fail hard we use our panic helpers instead.
The other supported SSL_TYPE do allow for panic, so we could issue the warning followed by panic:
docker-mailserver/target/scripts/helpers/error.sh
Lines 41 to 42 in 8f97171
| exit 1 | |
| _dms_panic__misconfigured 'acme.json' "${SCOPE_SSL_TYPE}" |
I'm not sure if that's the right choice, it's technically a misconfiguration related to it though. The logs would provide context of the warning prior to it and the SSL_TYPE=letsencrypt scope.
There was a problem hiding this comment.
Using the panic above would trigger DMS shutdown fully, even from the check-for-changes.sh script noticing an updated acme.json if it fails to extract the cert.
I think we may not have panicked here as the acme.json was less explicit of a config vs the others, and the user had less direct control/influence over that 🤷♂️
You don't need to apply the suggestion, we could just revert the exit 1 you've added. Feel free to wait for another maintainer to share their opinion on what should happen 👍
In the extraction method call, you'll see some other failures are logged with warn and a return 1 (which is the proper way to exit a function early).
| exit 1 | |
| return 1 |
| # Variable only intended for troubleshooting via debug output | ||
| local EXTRACTED_DOMAIN | ||
|
|
||
| _log 'debug' "Detected caddy certificates" | ||
|
|
||
| # Conditional handling depends on the success of `_extract_certs_from_caddy_data`, | ||
| # Failure tries the next fallback FQDN to try extract a certificate from. | ||
| # Subshell not used in conditional to ensure extraction log output is still captured | ||
| if [[ -n ${SSL_DOMAIN} ]] && _extract_certs_from_caddy_data "${SSL_DOMAIN}"; then | ||
| EXTRACTED_DOMAIN=('SSL_DOMAIN' "${SSL_DOMAIN}") | ||
| elif _extract_certs_from_caddy_data "${HOSTNAME}"; then | ||
| EXTRACTED_DOMAIN=('HOSTNAME' "${HOSTNAME}") | ||
| elif _extract_certs_from_caddy_data "${DOMAINNAME}"; then | ||
| EXTRACTED_DOMAIN=('DOMAINNAME' "${DOMAINNAME}") | ||
| else | ||
| _log 'warn' "letsencrypt (caddy data) failed to identify a certificate to extract" | ||
| exit 1 | ||
| fi | ||
|
|
||
| _log 'trace' "letsencrypt (caddy data) extracted certificate using ${EXTRACTED_DOMAIN[0]}: '${EXTRACTED_DOMAIN[1]}'" |
There was a problem hiding this comment.
This is effectively copy/paste of the traefik method with the important distinction being calls to _extract_certs_from_caddy_data() instead of _extract_certs_from_acme().
As you're introducing a variant here, rather than duplicating this logic, you could instead detect caddy vs traefik handling into a common extract method.
| function _extract_certs_from_caddy_data() { | ||
| local CERT_DOMAIN=${1} | ||
| local SANITIZED_CERT_DOMAIN | ||
| if [[ -z ${CERT_DOMAIN} ]]; then | ||
| _log 'warn' "_extract_certs_from_caddy_data | CERT_DOMAIN is empty" | ||
| return 1 | ||
| fi | ||
|
|
||
| SANITIZED_CERT_DOMAIN=$(_caddy_sanitize_domain "${CERT_DOMAIN}") | ||
|
|
||
| # Currently we advise SSL_DOMAIN for wildcard support using a `*.example.com` value, | ||
| # The filepath however should be `example.com`, avoiding the wildcard part: | ||
| if [[ ${SSL_DOMAIN} == "${CERT_DOMAIN}" ]]; then | ||
| CERT_DOMAIN=$(_strip_wildcard_prefix "${SSL_DOMAIN}") | ||
| fi | ||
|
|
||
| mkdir -p "/etc/letsencrypt/live/${CERT_DOMAIN}/" | ||
| # Caddy can use different CA to obtain a certificate (Lets Encrypt, ZeroSSL or | ||
| # local). We don't know which one was used, so we use a '*' to match both. In | ||
| # case both exist (improbable), just the first one will be copied by GNU cp | ||
| cp -v /caddy_data/caddy/certificates/*/"${SANITIZED_CERT_DOMAIN}/${SANITIZED_CERT_DOMAIN}.key" "/etc/letsencrypt/live/${CERT_DOMAIN}/key.pem" || exit 1 | ||
| cp -v /caddy_data/caddy/certificates/*/"${SANITIZED_CERT_DOMAIN}/${SANITIZED_CERT_DOMAIN}.crt" "/etc/letsencrypt/live/${CERT_DOMAIN}/fullchain.pem" || exit 1 | ||
|
|
||
| _log 'trace' "_extract_certs_from_caddy_data | Certificate successfully extracted for '${CERT_DOMAIN}'" | ||
| } |
There was a problem hiding this comment.
Regarding earlier review feedback on a common extraction method, we can observe that there is common logic that can be shared, only a conditional is necessary for when traefik vs caddy differ. A slight refactor would make that a single conditional block?
Common check:
docker-mailserver/target/scripts/helpers/ssl.sh
Lines 415 to 419 in 8f97171
Attempt to retrieve / detect the certificate files (traefik example):
docker-mailserver/target/scripts/helpers/ssl.sh
Lines 421 to 428 in 8f97171
Common step (now that cert content is found, ensure directory for CERT_DOMAIN exists):
docker-mailserver/target/scripts/helpers/ssl.sh
Lines 430 to 436 in 8f97171
NOTE: The above SSL_DOMAIN is planned for removal (unnecessary legacy config contribtued a while back)
Final step, populate a copy of the cert content to internal files DMS is configured to use (traefik example):
docker-mailserver/target/scripts/helpers/ssl.sh
Lines 437 to 440 in 8f97171
| # Make the same substitutions that Caddy does on domains | ||
| # This is a translation to sh of the Safe function in | ||
| # https://github.com/caddyserver/certmagic/blob/v0.19.1/storage.go#L242 | ||
| function _caddy_sanitize_domain() { | ||
| local CERT_DOMAIN=${1,,} # lowercase | ||
| CERT_DOMAIN=${CERT_DOMAIN##+([[:space:]])} # trim leading spaces | ||
| CERT_DOMAIN=${CERT_DOMAIN%%+([[:space:]])} # trim trailing spaces | ||
|
|
||
| # replace a few specific characters | ||
| CERT_DOMAIN=$(sed ' | ||
| s/ /_/g; | ||
| s/+/_plus_/g; | ||
| s/*/wildcard_/g; | ||
| s/:/-/g; | ||
| s/\.\.//g; | ||
| ' <<< "${CERT_DOMAIN}") | ||
|
|
||
| # Remove all non-word characters and print result to stdout | ||
| # Note: \w in the original code is equivalent to [:alnum:]_ | ||
| echo "${CERT_DOMAIN//[^[:alnum:][email protected]]/}" | ||
| } |
There was a problem hiding this comment.
The CertMagic referenced code is used for plenty of methods to sanitize a filepath. Most of these should not be relevant for DMS support as CERT_DOMAIN should already be aligned with a valid hostname (mail.example.com or example.com). The only special handling we've had was for SSL_DOMAIN=*.example.com but as no *. value is relevant to supporting the Traefik acme.json feature it's only a UX value that we'd rather just drop.
I've not looked at the filenames Caddy is generating, I assume wildcard_ prefix is the only one you'd encounter? If so you're just wanting to check if wildcard_${DOMAINNAME} is a valid match in the filesystem before $HOSTNAME and $DOMAINNAME, like we do with SSL_DOMAIN?
This method could instead just check for existence of the file like we do in a later step:
docker-mailserver/target/scripts/helpers/ssl.sh
Lines 375 to 391 in 8f97171
@georglauterbach or @casperklein might have a better suggestion to scan your child dirs of /caddy_data/caddy/certificates/ for existence of the directory or file "${CERT_DOMAIN}/${CERT_DOMAIN}.key". Probably with the find command. Then I suppose running the same check first with wildcard_ prepended to ${CERT_DOMAIN} instead would do the trick 😅
There was a problem hiding this comment.
Yes, just use find. It was written for this use case.
| - caddy_data:/caddy_data | ||
| environment: | ||
| SSL_TYPE: letsencrypt | ||
| SSL_DOMAIN: mail.example.com # Ensure this is exactly the same as the domain you pass to Caddy |
There was a problem hiding this comment.
Shouldn't be necessary once feedback is applied.
| SSL_DOMAIN: mail.example.com # Ensure this is exactly the same as the domain you pass to Caddy |
| caddy_data: | ||
|
|
||
| services: | ||
| caddy: |
There was a problem hiding this comment.
Consistency with nginx + traefik example configs:
| caddy: | |
| reverse-proxy: |
| labels: | ||
| caddy.local_certs: # Remove this label when going to production | ||
|
|
||
| dms: |
There was a problem hiding this comment.
Consistency with nginx + traefik example configs:
| dms: | |
| mailserver: |
| [Caddy](https://caddyserver.com/) is an open-source web server with built-in SSL certificate generation. You can use the [official Docker image](https://hub.docker.com/_/caddy) and write your own Caddyfile, or use [lucaslorentz/caddy-docker-proxy](https://github.com/lucaslorentz/caddy-docker-proxy), which lets you add labels to your services in order to generate a Caddyfile. | ||
|
|
||
| The generated certificates can then be mounted: | ||
| DMS supports retrieving certificates from Caddy data when the local file system storage is used (the default). You just need to mount your Caddy data and set `SSL_TYPE: letsencrypt` in the DMS container. Here is a minimal example with `lucaslorentz/caddy-docker-proxy`: |
There was a problem hiding this comment.
You removed the admonition (example type, the purple collapsible section in our docs): ??? example
Adding that back requires 4 space indentation of the codeblock that follows, like the JSON snippet prior was.
| } | ||
| } | ||
| ``` | ||
| [Caddy](https://caddyserver.com/) is an open-source web server with built-in SSL certificate generation. You can use the [official Docker image](https://hub.docker.com/_/caddy) and write your own Caddyfile, or use [lucaslorentz/caddy-docker-proxy](https://github.com/lucaslorentz/caddy-docker-proxy), which lets you add labels to your services in order to generate a Caddyfile. |
There was a problem hiding this comment.
While the Caddy section was outdated a fair bit, you have dropped information about configuring the key type (Caddyfile and JSON examples). The self-generated and nginx-proxy sections both touch on this too, but it's most likely not too relevant / specific to DMS, so dropping these old examples is acceptable.
| - ${CADDY_DATA_DIR}/certificates/acme-v02.api.letsencrypt.org-directory/mail.example.com/mail.example.com.crt:/etc/letsencrypt/live/mail.example.com/fullchain.pem | ||
| - ${CADDY_DATA_DIR}/certificates/acme-v02.api.letsencrypt.org-directory/mail.example.com/mail.example.com.key:/etc/letsencrypt/live/mail.example.com/privkey.pem |
There was a problem hiding this comment.
And here in the old docs we have an example filepath that your PR presumably operates on and is still roughly the same?
Like with certbot section, it advises mounting the relevant cert folder to /etc/letsencrypt/live location that DMS will internally check for with SSL_TYPE=letsencrypt:
docker-mailserver/target/scripts/helpers/ssl.sh
Lines 394 to 412 in 8f97171
Traefik has special support because of the acme.json format. Whereas I feel this PR is adding a very minor convenience to the user?
Our docs do cover SSL_TYPE=manual as an alternative to SSL_TYPE=letsencrypt. Both of which have check-for-changes.sh support as their locations are monitored:
docker-mailserver/target/scripts/helpers/change-detection.sh
Lines 46 to 63 in 8f97171
However in your current PR state, your approach for Caddy would miss out on this and would not benefit. The Traefik acme.json support is hard-coded into our /etc/letsencrypt internal location which has been considered acceptable. While for this Caddy support we need to also rely on /caddy_data to monitor for changes (ideally only for those relevant to DMS, which is better supported if mounting the full path to isolate if the reverse-proxy was using the same data folder for other services, as shown with rspamd).
The difference with normal SSL_TYPE=letsencrypt is that unlike SSL_TYPE=manual there is no internal copy made. I'd like to simplify the SSL / TLS support, but haven't found time to. Ideally it'd be more generic in finding a cert from some location and then copying it to a fixed internal location. I understand the desire for convenience though.
There was a problem hiding this comment.
And here in the old docs we have an example filepath that your PR presumably operates on and is still roughly the same?
Whereas I feel this PR is adding a very minor convenience to the user?
The reason for implementing Caddy support is that we can't know in advance the path where Caddy will store the certificates. Caddy uses Lets Encrypt or ZeroSSL by default, and may be configured to issue local certs, or use another CA. The paths depends on the issuer, and may look like the paths in the old docs, but they may also have acme.zerossl.com-v2-dv90 (or local, or other) instead of acme-v02.api.letsencrypt.org-directory
Th paths could also change over time, for example if a certificate is initially issued with Lets Encrypt, but for some reason the renewal fails and Caddy falls back to ZeroSSL.
|
Hello and thanks for the extended review ! As you say I should have opened an issue to discuss this first. The changes required to support Caddy are bigger than I originally thought. I was not aware of the internals of DMS, such as I think that support for multiple «certificate managers» may be implemented in a modular way with «extractors», I'm opening an issue to discuss this from scratch. If you're okay with the approach, I can transform this PR to «Add a Caddy extractor» (or open a new one). |
A new PR would be better thanks. Feel free to close this one if you'd like to pursue the alternative discussed in your new issue. There's a possibility I may be able to tackle it but if I don't find time by September I doubt it'll be any time soon 😅 I outlined in the issue what a rework would look like, I have some familiarity with what commands can be used to accomplish the different tasks. In the meantime, I believe Caddy can be configured to restrict the providers to a single one if you choose to, which would avoid your concern with potential vendor changing allowing for |
|
Ok ! I'm Closing this PR then. I'll wait for your refactor of Yes, Caddy can be configured to use a single CA, with the |
Description
This PR adds support for extracting certificates from Caddy v2 data, as this is already supported for Traefik's
acme.json.Type of change
Checklist:
docs/)