ACME Server 实践之 CDNS
![Cover Image ACME Server 实践之 CDNS](https://george.betterde.com/covers/20240722-cdns-challenge.jpg)
前言⌗
在之前的文章《ACME Server 实践之 ACME DNS》 一文中,采用 ACME DNS 作为内网 DNS Challenge 的 DNS 解析服务,发现效果并不理想,主要原因如下:
- 无法获取
_acme-challenge.example.com
的 TXT 记录 - 需要人为的在 ACME DNS 的配置文件中增加
_acme-challenge.example.com
到 ACME DNS Subdomain 的 CNAME 记录
意外发现⌗
熟悉 dnsChallenge 流程的话,不难发现, CNAME 记录只是曲线救国,更直接的方法应该是,我们的 DNS 支持 RESTful API,来追加和删除解析记录。
顺着这个思路,我阅读 Traefik 的源码,发现他的 ACME Client 实现依赖于 go-acme/lego 这个库,而这个库支持一种叫做 HTTP Request 的 dnsChallenge Provider。
这个 Provider 会向指定的 DNS 服务发送两个请求,分别是:
- /present: 创建解析记录
- /cleanup: 删除解析记录
请求的参数如下:
{
"fqdn": "_acme-challenge.domain.",
"value": "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"
}
- fqdn: 就是要申请证书的域名
- value: Stemp-CA 那里生产的 Token
更多细节可以自行阅读 go-acme/lego 的官方文档。
那么也即是说,我们只要搞个 DNS 实现上述两个接口即可!
实现⌗
RESTful API 的实现如下:
package handler
import (
"github.com/betterde/cdns/internal/response"
"github.com/betterde/cdns/pkg/dns"
"github.com/gofiber/fiber/v2"
record "github.com/miekg/dns"
"strings"
)
type Request struct {
FQDN string `json:"fqdn"`
Value string `json:"value"`
}
func Present(ctx *fiber.Ctx) error {
payload := Request{}
err := ctx.BodyParser(&payload)
if err != nil {
return ctx.JSON(response.ValidationError("Payload validation failed", err))
}
for _, server := range dns.Servers {
txtRecord := &record.TXT{
Hdr: record.RR_Header{
Name: payload.FQDN,
Rrtype: record.TypeTXT,
Class: record.ClassINET,
Ttl: 3600,
},
Txt: []string{payload.Value},
}
domain := server.Domains[payload.FQDN]
domain.Records = append(server.Domains[payload.FQDN].Records, txtRecord)
server.Domains[payload.FQDN] = domain
}
return ctx.JSON(response.Success("Success", nil))
}
// Cleanup delete TXT record
func Cleanup(ctx *fiber.Ctx) error {
payload := Request{}
err := ctx.BodyParser(&payload)
if err != nil {
return ctx.JSON(response.ValidationError("Payload validation failed", err))
}
for _, server := range dns.Servers {
domain := server.Domains[payload.FQDN]
result := make([]record.RR, 0)
for _, rec := range domain.Records {
txtSlice := rec.(*record.TXT).Txt
if strings.Join(txtSlice, "") != payload.Value {
result = append(result, rec)
}
}
server.Domains[payload.FQDN] = dns.Records{Records: result}
}
return ctx.JSON(response.Success("Success", nil))
}
部署⌗
CDNS 的 docker-compose.yaml
:
services:
cdns:
image: betterde/cdns:latest
labels:
- traefik.enable=false
restart: always
volumes:
- ./certs:/certs
- ./config:/etc/cdns
command: ["serve", "--verbose"]
hostname: cdns
networks:
traefik:
ipv4_address: 10.8.10.253
extra_hosts:
- ca.svc.dev:10.8.10.254
environment:
# General configration
- CDNS_ENV=production
- CDNS_LOGGING_LEVEL=DEBUG
# DNS configration
- CDNS_DNS_LISTEN=0.0.0.0:53
- CDNS_DNS_PROTOCOL=both
# API configuration
- CDNS_HTTP_TLS_MODE=acme
- CDNS_HTTP_DOMAIN=dns.svc.dev
- CDNS_HTTP_LISTEN=0.0.0.0:443
# TLS ACME provider
- [email protected]
- CDNS_PROVIDERS_ACME_SERVER=https://ca.svc.dev/acme/acme/directory
- CDNS_PROVIDERS_ACME_STORAGE=/certs
# TLS File provider
- CDNS_PROVIDERS_FILE_TLSKEY=/certs/domain.tld.key
- CDNS_PROVIDERS_FILE_TLSCERT=/certs/domain.tld.crt
container_name: cdns
networks:
traefik:
external: true
- 15 行: 手动指定 CDNS 容器的 IP
- 16 行: 手动指定 Smallstep CA 容器的 IP
服务所需要的配置信息,可以通过环境变量来设置,只需要加上 CDNS_
的前缀,将 .cdns.yaml
中的配置项进行展平,并用 _
来进行拼接即可覆盖配置文件中的设置!
CDNS 的 .cdns.yaml
:
ns:
ip: 10.8.10.253
dns:
admin: george.dev
listen: 0.0.0.0:53
nsname: dev
protocol: both
soa:
domain: dev
http:
tls:
mode: acme # The tls mode support "acme" and "file".
domain: dns.svc.dev
listen: 0.0.0.0:443
# username: traefik
# password: tdejdytJkvZEcOXvaEyIOXt3bqnl9FpZ
ingress:
ip: 10.8.10.252
logging:
level: INFO
providers:
acme:
email: [email protected]
server: https://ca.svc.dev/acme/acme/dictory
storage: /certs/acme
file:
tlsKey: /certs/domain.tld.key
tlsCert: /certs/domain.tld.crt
启动 CDNS 容器:
docker compose up
{"level":"debug","ts":"2024-07-22T02:22:08.840Z","msg":"Configuration file currently in use: /etc/cdns/.cdns.yaml"}
{"level":"debug","ts":"2024-07-22T02:22:08.840Z","msg":"Adding new record to domain","Domain":"dev.","RecordType":"SOA"}
{"level":"debug","ts":"2024-07-22T02:22:08.840Z","msg":"Adding new record to domain","Domain":"dev.","RecordType":"SOA"}
{"level":"debug","ts":"2024-07-22T02:22:08.840Z","msg":"Listening DNS","Addr":"0.0.0.0:53","Proto":"tcp"}
{"level":"debug","ts":"2024-07-22T02:22:08.840Z","msg":"Listening DNS","Addr":"0.0.0.0:53","Proto":"udp"}
┌───────────────────────────────────────────────────┐
│ CDNS │
│ Fiber v2.52.5 │
│ https://[::]:443 │
│ │
│ Handlers ............ 11 Processes ........... 1 │
│ Prefork ....... Disabled PID ................. 1 │
└───────────────────────────────────────────────────┘
info maintenance started background certificate maintenance {"cache": "0x4000226000"}
info maintenance started background certificate maintenance {"cache": "0x4000226080"}
info obtain acquiring lock {"identifier": "dns.svc.dev"}
info obtain lock acquired {"identifier": "dns.svc.dev"}
info obtain obtaining certificate {"identifier": "dns.svc.dev"}
info waiting on internal rate limiter {"identifiers": ["dns.svc.dev"], "ca": "https://ca.svc.dev/acme/acme/directory", "account": "[email protected]"}
info done waiting on internal rate limiter {"identifiers": ["dns.svc.dev"], "ca": "https://ca.svc.dev/acme/acme/directory", "account": "[email protected]"}
info using ACME account {"account_id": "https://ca.svc.dev/acme/acme/account/SjxARMPwy9JkKvXw5PxGKOrDQHjqKQtu", "account_contact": ["mailto:[email protected]"]}
info acme_client trying to solve challenge {"identifier": "dns.svc.dev", "challenge_type": "dns-01", "ca": "https://ca.svc.dev/acme/acme/directory"}
{"level":"debug","ts":"2024-07-22T02:22:08.904Z","msg":"Answering question for domain","QType":"TXT","Domain":"_acme-challenge.dns.svc.dev.","RCode":"NOERROR"}
info acme_client authorization finalized {"identifier": "dns.svc.dev", "authz_status": "valid"}
info acme_client validations succeeded; finalizing order {"order": "https://ca.svc.dev/acme/acme/order/MN3Pv3jeHr4J5g7VUNDM4lYv4zYdMps2"}
info acme_client successfully downloaded available certificate chains {"count": 1, "first_url": "https://ca.svc.dev/acme/acme/certificate/7tTjTnkwEe3bGfgPBtoLHyZtIsTisnmr"}
info obtain certificate obtained successfully {"identifier": "dns.svc.dev", "issuer": "ca.svc.dev-acme-acme-directory"}
info obtain releasing lock {"identifier": "dns.svc.dev"}
warn stapling OCSP {"error": "no OCSP stapling for [dns.svc.dev]: no OCSP server specified in certificate", "identifiers": ["dns.svc.dev"]}
- 27-30 行: CDNS 从 Step-CA 那里申请到了
dns.svc.dev
的证书
到这里,CDNS 这边就没有什么问题了!
Traefik⌗
当 CDNS 启动后,还需要修改 Traefik 的 dnsChallenge 配置:
services:
traefik:
dns:
- 10.8.10.253
image: traefik:latest
ports:
- 0.0.0.0:80:80/tcp
- 0.0.0.0:443:443/tcp
restart: always
hostname: traefik
networks:
traefik:
ipv4_address: 10.8.10.252
command:
- --api=true
- --api.dashboard=true
......
......
- [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=httpreq
- --certificatesresolvers.step-ca.acme.httpChallenge=false
volumes:
- step-ca:/step-ca:ro
- certs:/etc/ssl/certs:ro
- ./certs/:/certs/:rw
- ./config/:/etc/traefik/config/:ro
- /var/run/docker.sock:/var/run/docker.sock
logging:
driver: json-file
options:
max-size: 32m
dns_search:
- svc.dev
extra_hosts:
- ca.svc.dev:10.8.10.254
- dns.svc.dev:10.8.10.253
environment:
- TZ=Asia/Shanghai
- HTTPREQ_ENDPOINT=https://dns.svc.dev
- LEGO_CA_CERTIFICATES=/step-ca/certs/root_ca.crt
container_name: traefik
volumes:
certs:
name: certs
external: true
step-ca:
name: step-ca
external: true
networks:
traefik:
external: true
- 25-26 行: 设置
dnsChallenge
的 Provider 为httpreq
- 45 行: 设置 CDNS API 的 Endpoint
总结⌗
这样就完成了所有的配置,这一套环境能够为所有 *.dev
的域名自动签发证书!这是基于 Traefik,如果要适配其他的 ACME Client,还有很长的路要走!
目前为止 CDNS 还不完善,仅满足我个人的内网环境需求,如果你也想搭建一套内网的 PKI,可以联系我……
如果你想为此项目共享,可以访问 betterde/cdns。
I hope this is helpful, Happy hacking…