ACME Server Practice with CDNS

Introduction⌗
In the previous article “ACME Server Practice with ACME DNS”, ACME DNS was used as the DNS resolution service for internal network DNS Challenge, but the effect was not ideal. The main reasons are as follows:
- Unable to get the TXT record of
_acme-challenge.example.com
- Need to manually add a CNAME record from
_acme-challenge.example.com
to ACME DNS Subdomain in the ACME DNS configuration file
Unexpected Discovery⌗
If you are familiar with the dnsChallenge process, it’s not difficult to find that CNAME records are just a workaround. A more direct method should be that our DNS supports RESTful API to add and delete resolution records.
Following this idea, I read Traefik’s source code and found that its ACME Client implementation relies on the go-acme/lego library, which supports a dnsChallenge Provider called HTTP Request.
This Provider sends two requests to the specified DNS service:
- /present: Create resolution record
- /cleanup: Delete resolution record
The request parameters are as follows:
{
"fqdn": "_acme-challenge.domain.",
"value": "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"
}
- fqdn: The domain name for which the certificate is being applied
- value: Token generated by Stemp-CA
For more details, please read the official documentation of go-acme/lego.
So that means we just need to implement a DNS with these two interfaces!
Implementation⌗
The RESTful API implementation is as follows:
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))
}
Deployment⌗
CDNS’s 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
- Line 15: Manually specify the IP of the CDNS container
- Line 16: Manually specify the IP of the Smallstep CA container
The configuration information required by the service can be set through environment variables. Just add the CDNS_
prefix, flatten the configuration items in .cdns.yaml
, and concatenate them with _
to override the settings in the configuration file!
CDNS’s .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
Start the CDNS container:
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"]}
- Lines 27-30: CDNS obtained the certificate for
dns.svc.dev
from Step-CA
At this point, there are no more issues with CDNS!
Traefik⌗
After CDNS is started, we also need to modify Traefik’s dnsChallenge configuration:
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
- Lines 25-26: Set the
dnsChallenge
Provider tohttpreq
- Line 45: Set the CDNS API Endpoint
Conclusion⌗
This completes all the configurations. This environment can automatically issue certificates for all *.dev
domains! This is based on Traefik. If you want to adapt to other ACME Clients, there is still a long way to go!
Currently, CDNS is not yet perfect and only meets my personal internal network needs. If you also want to build a set of internal PKI, you can contact me…
If you want to contribute to this project, you can visit betterde/cdns.
I hope this is helpful, Happy hacking…