ACME Server 实践之 ACME DNS
前言⌗
在 《Traefik 结合 Step-CA 实现自动 HTTPS》 一文中,我们已经实现了基于 tlsChallenge
和 httpChallenge
的证书申请方式。但是这两种方式都无法支持通配证书的申请。
要申请通配证书,必须通过 dnsChallenge 来实现,而 dnsChallenge 的大致流程如下:
也就是说,这里面最核心的就是 DNS API,通过 DNS API 向 DNS 中添加 TXT 记录,该记录的值由 CA 生成,返回给 ACME 客户端,由客户端通过 API 的形式写入 DNS,然后 CA 再通过 DNS 查询去验证这个域名是否属于申请者!
ACME DNS⌗
通常云 DNS 服务商都有提供对应的 ACME 支持,但是部分传统的 DNS 服务商可能不支持 ACME 协议,所以就有了 ACME DNS!有兴趣的可以阅读一下这篇文章 A Technical Deep Dive: Securing the Automation of ACME DNS Challenge Validation。
项目仓库:joohoi/acme-dns
ACME DNS 的配置如下:
[general]
# DNS interface. Note that systemd-resolved may reserve port 53 on 127.0.0.53
# In this case acme-dns will error out and you will need to define the listening interface
# for example: listen = "127.0.0.1:53"
listen = "0.0.0.0:53"
# protocol, "both", "both4", "both6", "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6"
protocol = "both"
# domain name to serve the requests off of
domain = "dns.svc.dev"
# zone name server
nsname = "dns.svc.dev"
# admin email address, where @ is substituted with .
nsadmin = "admin.svc.dev"
# predefined records served in addition to the TXT
records = [
# Traefik container IP
"*.svc.dev. A 10.8.10.252",
# Step-CA container IP
"ca.svc.dev. A 10.8.10.254",
# domain pointing to the public IP of your acme-dns server
"dns.svc.dev. A 10.8.10.253",
# specify that auth.example.org will resolve any *.auth.example.org records
"dns.svc.dev. NS dns.svc.dev."
]
# debug messages from CORS etc
debug = true
[database]
# Database engine to use, sqlite3 or postgres
engine = "sqlite3"
# Connection string, filename for sqlite3 and postgres://$username:$password@$host/$db_name for postgres
# Please note that the default Docker image uses path /var/lib/acme-dns/acme-dns.db for sqlite3
connection = "/database/acme-dns.db"
# connection = "postgres://acme-dns:[email protected]:5432/acme-dns?sslmode=disable"
[api]
# listen ip eg. 127.0.0.1
ip = "0.0.0.0"
# possible values: "letsencrypt", "letsencryptstaging", "cert", "none"
tls = "letsencrypt"
# listen port, eg. 443 for default HTTPS
port = "443"
# disable registration endpoint
disable_registration = false
# only used if tls = "cert"
tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
tls_cert_fullchain = "/etc/tls/example.org/fullchain.pem"
# only used if tls = "letsencrypt"
acme_cache_dir = "certs"
# optional e-mail address to which Let's Encrypt will send expiration notices for the API's cert
notification_email = "[email protected]"
# CORS AllowOrigins, wildcards can be used
corsorigins = [
"*"
]
# use HTTP header to get the client ip
use_header = false
# header name to pull the ip address / list of ip addresses from
header_name = "X-Forwarded-For"
[logconfig]
# logging level: "error", "warning", "info" or "debug"
loglevel = "debug"
# possible values: stdout, TODO file & integrations
logtype = "stdout"
# file path for logfile TODO
# logfile = "./acme-dns.log"
# format, either "json" or "text"
logformat = "text"
- 45 行: 我为 ACME DNS 的 API 也开启了 HTTPS,因为他本身也集成了支持 ACME 协议的客户端。
如果 ACME DNS API 开启 HTTPS 需要注意的问题:
ACME DNS 仅支持
letsencrypt
和letsencryptstaging
,这两个 API 的 Endpoint,是硬编码到程序中的,所以如果要使用本地的 Step-CA 来替换letsencrypt
或letsencryptstaging
,还需要在运行容器时做一些设置!并且因为 Step-CA 的 HTTPS 是不被信任的,所以需要在容器中添加 Step-CA 的根证书,具体方法可以参考《在本地 Docker 环境中信任自签名 CA 证书》 这篇文章。在 Step-CA 的容器的环境变量
DOCKER_STEPCA_INIT_DNS_NAMES
中添加上letsencrypt
和letsencryptstaging
的域名,这样当 ACME DNS 将请求发送到 Step-CA 时,才能正常通信!
下面是我按照官方的 Self-hosted 文档编写的 docker-compose.yaml
:
services:
acme-dns:
image: joohoi/acme-dns:latest
labels:
- traefik.enable=false
restart: always
volumes:
- ./certs:/certs
- ./database:/database
- ./config:/etc/acme-dns:ro
hostname: acme-dns
networks:
traefik:
ipv4_address: 10.8.10.253
extra_hosts:
- ca.svc.dev:10.8.10.254
- acme-v02.api.letsencrypt.org:10.8.10.254
- acme-staging-v02.api.letsencrypt.org:10.8.10.254
environment:
- TZ=Asia/Shanghai
container_name: acme-dns
networks:
traefik:
external: true
- 16~18 行:强行将
letsencrypt
和letsencryptstaging
的域名指向到内网的 Step-CA 容器 IP,这一步很重要!
ACME 服务启动后,查看 API 服务是否从 Step-CA 获取到了证书,如果一切正常,则通过下面的方法测试 API 是否正常:
curl -X POST https://dns.svc.dev/register | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 235 100 235 0 0 768 0 --:--:-- --:--:-- --:--:-- 767
{
"username": "e1181993-6e69-4f4b-90f5-e33f383d5444",
"password": "FUfLiaavn0e4ssrtJZbVt7FimNBgDvsEerRkkVPx",
"fulldomain": "008a8c8a-d5a8-4ea6-964e-651f09220763.dns.svc.dev",
"subdomain": "008a8c8a-d5a8-4ea6-964e-651f09220763",
"allowfrom": []
}
API 正常后,我们还需要配置 Traefik 的 docker-compose.yaml
文件:
services:
traefik:
......
networks:
traefik:
ipv4_address: 10.8.10.252
command:
......
- --entrypoints.https.address=:443
- --entryPoints.https.http3.advertisedport=443
- --entryPoints.https.http.tls.certResolver=step-ca
- --entryPoints.https.http.tls.domains[0].main=svc.dev
- --entryPoints.https.http.tls.domains[0].sans=*.svc.dev
- [email protected]
- --certificatesresolvers.step-ca.acme.storage=/certs/acme.json
- --certificatesresolvers.step-ca.acme.caserver=https://ca.svc.dev/acme/acme/directory
- --certificatesresolvers.step-ca.acme.tlschallenge=false
- --certificatesresolvers.step-ca.acme.dnschallenge=true
- --certificatesresolvers.step-ca.acme.dnschallenge.provider=acme-dns
- --certificatesresolvers.step-ca.acme.httpChallenge=false
volumes:
- step-ca:/step-ca:ro
- ./certs/:/certs/:rw
- ./config/:/etc/traefik/config/:ro
- /var/run/docker.sock:/var/run/docker.sock
extra_hosts:
- ca.svc.dev:10.8.10.254
- dns.svc.dev:10.8.10.253
environment:
- TZ=Asia/Shanghai
- ACME_DNS_API_BASE=https://dns.svc.dev
- ACME_DNS_STORAGE_PATH=/certs/lego-acme-dns-accounts.json
- LEGO_CA_CERTIFICATES=/step-ca/certs/root_ca.crt
- LEGO_DISABLE_CNAME_SUPPORT=false
container_name: traefik
volumes:
step-ca:
name: step-ca
external: true
networks:
traefik:
external: true
- 14 行:使用
acme-dns
作为 dnsChallenge 的 Provider - 27~28 行:配置
acme-dns
所需的环境变量
完成上述配置后,重新创建 Traefik 容器,不出意外的话,会在 certs
目录下创建 lego-acme-dns-accounts.json
文件,结构如下:
{
"FQDN": {
"username": "e1181993-6e69-4f4b-90f5-e33f383d5444",
"password": "FUfLiaavn0e4ssrtJZbVt7FimNBgDvsEerRkkVPx",
"subdomian": "008a8c8a-d5a8-4ea6-964e-651f09220763",
"fulldumain:": "008a8c8a-d5a8-4ea6-964e-651f09220763.dns.svc.dev",
"allowfrom": ["IP"]
}
}
因为我们想为 *.svc.dev
申请通配证书,这时候需要我们在 ACME DNS 的配置文件的 general.records
数组中配置一个 _acme-challenge.svc.dev
的 CNAME 记录:
records = [
"_acme-challenge.svc.dev. CNAME 008a8c8a-d5a8-4ea6-964e-651f09220763.dns.svc.dev.",
]
然后重启 ACME DNS,等待 Traefik 和 Step-CA 执行整个 dnsChallenge 流程……
原理分析⌗
ACME DNS 这种方案,本身就属于是曲线救国,其关键就在于 CNAME,因为权威服务器不支持 DNS API,所以只能把 _acme_challenge.tld.
的查询请求通过 CNAME 委托给 ACME DNS 来处理和响应查询。其大致流程如下:
- Traefik 根据路由中定义的 FQDN,检查
certs/acme.json
中是否存在该 FQDN 的证书 - 如果证书不存在,则检测
certs/lego-acme-dns-accounts.json
文件中是否存在该 FQDN 的账户 - 如果不存在 ACME DNS 的 Account,则调用
/register
API 创建,然后将结果写入certs/lego-acme-dns-accounts.json
- 向 Step-CA 发送申请,获得用于验证的 Token 后,通过 ACME DNS 的
/update
API 创建一条008a8c8a-d5a8-4ea6-964e-651f09220763.dns.svc.dev
的 TXT 记录用于验证 - Step-CA 向 DNS 发起查询请求,但是因为
008a8c8a-d5a8-4ea6-964e-651f09220763.dns.svc.dev
对于 CA 来说是无感的,他只会去验证查询_acme-challenge.svc.dev
这个 FQDN 是否有对应的 TXT 记录
所以在第 5 步之前,我们要手动在 ACME DNS 中添加 CNAME 记录,这也是让我感觉比较割裂的地方,如果我需要申请多个二级域名的 FQDN 通配证书,那么每个都需要我手动添加。
意外情况⌗
不出意外的话是要出意外了,虽然是前人走过的路,但是还是有一个大坑,至于是什么问题,可以看我提的 Issue,截止我发文,还为收到作者的解答!
那么接下来只能靠自己了……
CDNS 的诞生⌗
ACME DNS 看起来很美好,但是实际体验下来后,与我的预期相差甚远!我阅读源码后,修改了部分代码,让 Step-CA 能够正常验证 TXT,但这依然无法实现完全的自动签发证书!
其原因就是前面说到的,每个 FQDN 都需要添加一条 CNAME 解析记录,而这个没办法自动化来完成!于是乎我就诞生了一个想法,那就是自己开发一个专门用于内网 dnsChallenge
的 DNS。
项目地址:betterde/cdns,为什么要叫 CDNS 呢,因为是解决 dnsChallenge
问题的,所以其中的 C
也就是 Challenge 的简写。
通过这个项目,可以实现某个 TLD 下面的所有通配证书的申请和验证!具体配置和最终效果,我会在下一期文章中分享出来,敬请期待……
总结⌗
ACME 协议看似很完美,但是要想在内网中实现完全的自动化,还是要走很多弯路的。不过作为基础设施,一旦搭建完成后,后面对于开发来说,开发体验将是极其舒适的!至少在我的工作流中是这样,我不用再为每个项目手动生成通配证书,也不用在 Traefik 的配置文件中添加证书了!
I hope this is helpful, Happy hacking…