前言

在之前的文章《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…