前言

《Traefik 结合 Step-CA 实现自动 HTTPS》 一文中,我们已经实现了基于 tlsChallengehttpChallenge 的证书申请方式。但是这两种方式都无法支持通配证书的申请。

要申请通配证书,必须通过 dnsChallenge 来实现,而 dnsChallenge 的大致流程如下:

DNS Challenge workflow

也就是说,这里面最核心的就是 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 仅支持 letsencryptletsencryptstaging,这两个 API 的 Endpoint,是硬编码到程序中的,所以如果要使用本地的 Step-CA 来替换 letsencryptletsencryptstaging,还需要在运行容器时做一些设置!并且因为 Step-CA 的 HTTPS 是不被信任的,所以需要在容器中添加 Step-CA 的根证书,具体方法可以参考《在本地 Docker 环境中信任自签名 CA 证书》 这篇文章。

  • 在 Step-CA 的容器的环境变量 DOCKER_STEPCA_INIT_DNS_NAMES 中添加上 letsencryptletsencryptstaging 的域名,这样当 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 行:强行将 letsencryptletsencryptstaging 的域名指向到内网的 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 来处理和响应查询。其大致流程如下:

  1. Traefik 根据路由中定义的 FQDN,检查 certs/acme.json 中是否存在该 FQDN 的证书
  2. 如果证书不存在,则检测 certs/lego-acme-dns-accounts.json 文件中是否存在该 FQDN 的账户
  3. 如果不存在 ACME DNS 的 Account,则调用 /register API 创建,然后将结果写入 certs/lego-acme-dns-accounts.json
  4. 向 Step-CA 发送申请,获得用于验证的 Token 后,通过 ACME DNS 的 /update API 创建一条 008a8c8a-d5a8-4ea6-964e-651f09220763.dns.svc.dev 的 TXT 记录用于验证
  5. 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…