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 to httpreq
  • 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…