Authoritative DNS with deSEC and DNSControl

Until recently, I was hosting my DNS zones at the CommunityRack.org DNS servers. It was automated by storing classic BIND zonefiles in a Git repository, which were then processed by some scripts in a GitLab CI pipeline and the ultimately hosted by a Knot DNS server setup. This worked quite well, but I wanted something more modern, something which makes DNSSEC "just work" and provides an API.

deSEC DNS Hosting

My choice to host my DNS zones is deSEC, mostly because they offer a modern and secure hosting environment, are organized as a registered non-profit organization in Berlin, Germany, their stack is Open Source, and it is supported out of the box by DNSControl. This gives me a good feeling, not having my DNS zones at a commercial provider.
There is not much more to say, the registration was painless and after asking for having a higher quota than one zone, I was ready to go.

Automation with DNSControl

DNSControl describes itself best:

DNSControl is an opinionated platform for seamlessly managing your DNS configuration across any number of DNS hosts, both in the cloud or in your own infrastructure.

First, I migrated all my BIND zonefiles to DNSControl, a painless experience. Then I cleaned up and organized everything neatly, making the best out of DNSControl.

The main configuration file dnsconfig.js contains defaults and reusable parts:

var DSP_DESEC = NewDnsProvider("desec");
var REG_MONITOR = NewRegistrar("dohquad9");

// Global vars
var CAA_LE = [
    CAA_BUILDER({
        iodef: "mailto:tobias@tobru.ch",
        issue: ["letsencrypt.org"],
        issuewild: ["letsencrypt.org"]
    })
]

var MAILBOX_ORG_MAIL_RECORDS = [
    MX("@", 10, "mxext1.mailbox.org."),
    MX("@", 10, "mxext2.mailbox.org."),
    MX("@", 20, "mxext3.mailbox.org."),
    SPF_BUILDER({
        label: "@",
        parts: [
            "v=spf1",
            "include:mailbox.org",
            "include:eu.mailgun.org",
            "~all"
        ]
    }),
    CNAME("MBO0001._domainkey", "MBO0001._domainkey.mailbox.org."),
    CNAME("MBO0002._domainkey", "MBO0002._domainkey.mailbox.org."),
    CNAME("MBO0003._domainkey", "MBO0003._domainkey.mailbox.org."),
    CNAME("MBO0004._domainkey", "MBO0004._domainkey.mailbox.org."),
    TXT("_dmarc", "v=DMARC1;p=none;rua=mailto:postmaster@tobru.ch"),
]

// Defaults
DEFAULTS(
    DnsProvider(DSP_DESEC),
    NAMESERVER_TTL("1h"),
    DefaultTTL("1h"),
    CAA_LE,
    IGNORE("_acme-challenge", "TXT"),
    IGNORE("_acme-challenge.**", "TXT")
);

// Domains
require("domains/tobru.ch.js");

The var blocks can later be reused in the domains, while the DEFAULTS block sets sensible defaults for all my domains. Here is an example of a mostly empty domain:

D("example.com", REG_MONITOR,
    MAILBOX_ORG_MAIL_RECORDS
)

This is all what's needed to have a full-fledged domain ready, including E-Mail records. (I haven't added the E-Mail records to the defaults because not all my domains do have E-Mail enabled).

As I don't have my domain registered at a supported registrar, I'm using a special "DNS over HTTPS" registrar, called REG_MONITOR which makes sure that the DNS servers at the registrar actually point to deSEC.

DNSControl wants credentials stored in the file creds.json , and that's how mine looks like:

{
    "desec": {
      "TYPE": "DESEC",
      "auth-token": "$DESEC_TOKEN"
    },
    "dohquad9": {
      "TYPE": "DNSOVERHTTPS",
      "host": "9.9.9.9"
    }
  }

As you can see, I don't store any credentials directly in this file. The authentication token for deSEC comes from the environment variable DESEC_TOKEN.

When everything is properly configured and the access token is configured as an environment variable, DNSControl can be called with dnscontrol preview, which will show what it would do. When you're happy with the changes, a dnscontrol push actually makes the changes.

By storing all this in a Git repository, we can go one step further and automate the DNSControl flow.

Moar automation with Gitea Actions

Now that we have a working DNSControl setup and have everything stored in a Git repository, let's automate the flow. Here's how I configured the Gitea Action:

name: DNSControl Action

on:
  pull_request:
    branches: [ main ]
  push:
    branches: [ main ]

jobs:
  dnscontrol:
    runs-on: ubuntu-latest
    container: catthehacker/ubuntu:act-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Install dnscontrol
      uses: gacts/install-dnscontrol@v1
      with:
        version: "4.8.1"

    - name: Show dnscontrol version
      run: dnscontrol version

    - name: Run dnscontrol preview
      id: preview
      run: dnscontrol preview
      env:
        DESEC_TOKEN: ${{ secrets.DESEC_TOKEN }}

    - name: Run dnscontrol push
      if: github.ref == 'refs/heads/main' && github.event_name == 'push' && steps.preview.outcome == 'success'
      run: dnscontrol push
      env:
        DESEC_TOKEN: ${{ secrets.DESEC_TOKEN }}

When a Pull Request is created, it runs dnscontrol preview and when the change is pushed to the main branch, in addition to that it also runs dnscontrol push, but only when the preview succeeds.

The access token to the deSEC API needs to be stored as repository secret to be available to the workflow.

Integration with Kubernetes Cert-Manager

Another reason why I wanted a DNS hosting with an API was that I can use it to get Let's Encrypt certificates with DNS-01 challenge. Until now, I did that with acme-dns, but that always felt as a workaround.

There are multiple integrations of the deSEC API into Cert-Manager, I opted to go with irreleph4nt/cert-manager-webhook-desec-http.

As this creates DNS records directly via the deSEC API, DNSControl wouldn't be happy the next time it runs and would clean them up. Therefore, I add the IGNORE configuration as shown in the example above, which makes them cooperate well.

Conclusion

This is now my new setup for 2024 and beyond. I'm delighted with it! Pushing a change takes only a few seconds, the pipeline is very fast, as is the change propagation at deSEC.

I decided not to make my DNS Git repository public, as it might expose a bit too much, unfortunately. But I still hope this article helps to get an idea to potentially resemble the setup.

You've successfully subscribed to Tobias Brunner aka tobru
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.