Help us shape the future of Go! Take the 2026 Go Developer Survey

caddyhostdns

package module
v0.0.0-...-ed6a941 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 1 Imported by: 0

README

github.com/SvenDowideit/caddy-host-dns

Static, provider-agnostic DNS record management for Caddy.

dns_records is a Caddy global option (app module) that declares A, AAAA, and CNAME records for one or more dns.providers.* modules compiled into the same Caddy binary. It reconciles once at config load and again on every caddy reload: records are created or updated to match the config, and records can be removed with an explicit remove directive. The zone SOA can optionally be set (off by default) with an soa block. There is no polling, no public-IP discovery, and no per-provider code.

See caddy-dns-records-module.md for the full design.

Try it in 30 seconds

A Makefile wraps the common workflows. Requires Go and Docker:

make help        # list all targets
make test        # unit + Caddyfile parse tests (no network, no credentials)
make conformance # provider conformance smoke test (in-memory, no credentials)
make run         # build Caddy + module, start local PowerDNS, apply examples/Caddyfile
make pdns-down   # stop the local PowerDNS and clean up

make run builds a Caddy containing this module and the PowerDNS provider, starts a throwaway PowerDNS via docker compose, applies examples/Caddyfile, and serves on :8080. Inspect the created records in the local PowerDNS API or with make pdns-logs.

To prove a provider works end-to-end, the suite can be run against a local server (see Provider conformance tests):

make conformance-bind    # local BIND over RFC2136 — passes the full suite
make conformance-live    # local PowerDNS — see the note on TXT
make bind-down pdns-down # clean up

For a full black-box smoke test — Caddy serving HTTP from records it creates, modifies, and deletes in a containerized BIND — see e2e/:

make e2e                 # build Caddy (xcaddy) and run the containerized e2e
make e2e-download        # same, using a caddyserver.com/download binary

Install

Build a Caddy binary that includes this module and the DNS provider(s) you use:

xcaddy build \
  --with github.com/SvenDowideit/caddy-host-dns \
  --with github.com/caddy-dns/powerdns

Any module registered in the dns.providers namespace works; the app never names a provider in code.

Use

{
	dns_records {
		provider gandi {env.GANDI_TOKEN} {
			zone fi.gy
			record otel.fi.gy    A     10.10.0.5
			record otlp.fi.gy    A     10.10.0.6
			record gateway.fi.gy CNAME otel.fi.gy.
			record obs.fi.gy     A     10.10.0.7 ttl 5m
			remove stale.fi.gy   A
		}
		ttl 1h
	}
}

zone is optional: it is derived from each record's name when the provider implements libdns.ZoneLister, so set it (top-level as a default, or inside a provider block) when your provider does not support listing zones.

Equivalent JSON:

{
	"apps": {
		"dns_records": {
			"providers": [
				{
					"dns_provider": { "name": "gandi", "token": "{env.GANDI_TOKEN}" },
					"zone": "fi.gy",
					"records": [
						{ "name": "otel.fi.gy", "type": "A", "value": ["10.10.0.5"] },
						{ "name": "gateway.fi.gy", "type": "CNAME", "value": ["otel.fi.gy."] }
					],
					"remove": [
						{ "name": "stale.fi.gy", "type": "A" }
					]
				}
			],
			"ttl": "1h"
		}
	}
}
Semantics
  • A declared (name, type) RRset is owned: each reconcile brings it to exactly the declared values, pruning extras. Nothing else in the zone is touched.
  • Reconcile first reads the zone and compares: if the declared records already match, it makes no provider write at all. Changed A/AAAA records are updated in place — new values are appended before stale values are deleted, so an RRset is never momentarily empty. This avoids resolvers caching a transient NXDOMAIN/NODATA for hours. (Only the ttl of a record is refreshed by re-appending; a changed CNAME is replaced with a single SetRecords call.)
  • Removing a record from config does not delete it; use the remove directive to delete an entire (name, type) RRset or specific values on every reconcile. Removals are resolved against the zone's live records first, so they are TTL-independent.
  • zone is derived from the record name when the provider implements libdns.ZoneLister; otherwise set it explicitly (top-level or per provider).
  • best_effort downgrades reconcile failures to logged errors instead of failing the config load/reload.
Zone SOA (optional)

By default the app never touches the zone's SOA. Add an soa block to a provider to manage it, typically to set small, dynamic-DNS-friendly timers:

{
	dns_records {
		provider powerdns {env.PD_API_KEY} {
			zone fi.gy
			soa {
				mname ns1.fi.gy.          # primary NS; inherited from the live SOA if omitted
				rname hostmaster.fi.gy.   # responsible mailbox; inherited if omitted
				refresh 5m
				retry 5m
				expire 10m
				minimum 1m                # negative-caching TTL (RFC 2308)
				ttl 1m
				min_ttl 1m                # raise any duration to the provider's minimum
				# serial 42               # omit to increment the live serial (or use the clock)
			}
			record a.fi.gy A 10.10.0.1
		}
	}
}
  • Any timer left out uses a small default (refresh/retry 5m, expire 10m, minimum/ttl 1m), which keeps propagation fast for dynamic use.
  • min_ttl is a lower bound applied to every duration, so a provider's minimum TTL is never violated. There is no built-in per-provider minimum table — the app stays provider-agnostic — so set it from your provider's documentation when it enforces one.
  • The SOA is written with SetRecords (owning only the apex SOA RRset) and only when something changed, so reloads do not churn the serial. When the provider returns the SOA from GetRecords, mname, rname, and the serial are inherited from it and the serial is incremented on a change. If the provider does not return the SOA, set mname and rname explicitly; the serial then defaults to the current Unix time.
  • SOA management is advisory: if the provider rejects or silently ignores the write (many manage the SOA themselves), or the record cannot be built, this is logged as a warning and never fails the config load/reload. Records are unaffected by the app-level best_effort setting for this.

Provider conformance tests

The conformance package runs the official libdns test suite against a provider you compile into the test binary, proving the provider + its API settings + a zone all work together. The provider must be a dependency of the test binary; the bundled examples register PowerDNS and RFC2136, and you can add your own.

There are two ways to run it:

  • Against a provider's real API, by supplying its dns_provider JSON and a zone:

    CONFORMANCE_PROVIDER_JSON='{"name":"cloudflare","api_token":"..."}' \
    CONFORMANCE_ZONE=example.com. \
    go test -tags conformance -v ./conformance/...
    
  • Against a local server, using the provided docker compose services:

    make conformance-bind   # local BIND, RFC2136 dynamic updates — passes fully
    make conformance-live   # local PowerDNS — fails the TXT cases (see below)
    

Use a dedicated test zone. The suite creates, modifies, and deletes records named test-*. Never point it at a zone with real data.

A note on PowerDNS and TXT

make conformance-live currently fails the suite's TXT test cases, because of a known bug in the upstream libdns/powerdns provider: its read path (GetRecords) never unquotes TXT values, so they come back wrapped in extra quotes (e.g. "\"hello\"" instead of "hello"). libdns's TXT.RR() requires the Data to equal the unquoted Text, so the round-trip comparison fails. This is tracked upstream (see libdns/powerdns PR #12).

Everything else passes: the A, AAAA, and CNAME cases are green, and those are the only record types dns_records manages. This is exactly what a conformance suite is for — it tells you where the problem is. For a fully green local run, use make conformance-bind, which exercises the same wrapper against libdns/rfc2136 and a local BIND.

Adding your own provider

Add a test file with the provider imported and call the wrapper:

//go:build conformance

package conformance_test

import (
	"encoding/json"
	"testing"

	"github.com/SvenDowideit/caddy-host-dns/conformance"
	_ "github.com/caddy-dns/cloudflare"
)

func TestMyProvider(t *testing.T) {
	provider := json.RawMessage(`{"name":"cloudflare","api_token":"..."}`)
	conformance.Run(t, provider, "example.com.")
}

See conformance/example/providers_test.go for the PowerDNS and RFC2136 examples.

License

Apache-2.0. See LICENSE.

Documentation

Overview

Package caddyhostdns is the root package of the module. It exists so that a Caddy build can import the module root, e.g.

xcaddy build --with github.com/SvenDowideit/caddy-host-dns

and have the dns_records app registered.

Directories

Path Synopsis
Package conformance runs the official libdns provider test suite against a libdns provider, proving that the provider, its API settings, and a zone all work together.
Package conformance runs the official libdns provider test suite against a libdns provider, proving that the provider, its API settings, and a zone all work together.
Package dnsrec implements the "dns_records" Caddy app module: a static, provider-agnostic declaration of A, AAAA, and CNAME records across any dns.providers.* modules compiled into the Caddy binary.
Package dnsrec implements the "dns_records" Caddy app module: a static, provider-agnostic declaration of A, AAAA, and CNAME records across any dns.providers.* modules compiled into the Caddy binary.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL