From 066ba1892a4d630030893314e427dcfb70403170 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 14 May 2026 01:12:25 -0600 Subject: [PATCH] coredns: DoT (:853) + DoH (:443) listeners with self-signed cert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Corefile snippet (common) shared across plain DNS / DoT / DoH so zone-loading + forward + cache stay DRY across all three transports - scripts/generate-certs.sh: openssl-only self-signed RSA cert with SANs for localhost / 127.0.0.1 / ::1 / coredns / dns.local. Idempotent — skips regeneration if cert is valid >24h ahead; FORCE=1 to rotate. - Key chmod is 0644 so the CoreDNS container's nonroot user can read it via the bind mount. Acceptable for local dev; production should mount real certs with proper UID/GID. - DOT_PORT=8853, DOH_PORT=8443 (avoids Caddy already-on-443 collision) - Makefile: `make certs`, `make test-tls` - All three transports verified end-to-end (dig +tls, dig +https, curl with raw RFC 8484 wire format) --- .env | 8 +++++++ .gitignore | 3 +++ Corefile | 36 ++++++++++++++++++++---------- Makefile | 32 +++++++++++++++++++++----- docker-compose.yml | 3 +++ scripts/generate-certs.sh | 47 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 18 deletions(-) create mode 100755 scripts/generate-certs.sh diff --git a/.env b/.env index 09e8fe8..079ee15 100644 --- a/.env +++ b/.env @@ -8,3 +8,11 @@ COREDNS_IMAGE=coredns/coredns:1.11.3 DNS_PORT=1053 METRICS_PORT=9153 HEALTH_PORT=8080 + +# DoT (DNS-over-TLS, RFC 7858) — IANA port 853. Host port 8853 to +# stay unprivileged. +DOT_PORT=8853 + +# DoH (DNS-over-HTTPS, RFC 8484) — typically 443. Host port 8443 +# because Caddy already owns 443 on this host. +DOH_PORT=8443 diff --git a/.gitignore b/.gitignore index 801bdd4..b4fd195 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # Prepared zones are generated from zones/ by scripts/prepare-zones.sh zones-prepared/*.zone +# Self-signed certs (re-generated by scripts/generate-certs.sh) +certs/*.pem + # Local-only env overrides .env.local diff --git a/Corefile b/Corefile index 4753673..96699d3 100644 --- a/Corefile +++ b/Corefile @@ -1,25 +1,37 @@ -. { - # Authoritative: load every .zone in /zones via the auto plugin. - # Filename pattern (.*)\.zone yields the zone name from the first group. - # CoreDNS reloads modified files every 30s. +# Shared zone-loading + recursive-forwarding config. +# CoreDNS snippets are textually expanded by `import`, so we keep anything +# that's not transport-specific (TLS) in here. +(common) { auto { directory /zones (.*)\.zone {1} reload 30s } - - # Anything not authoritative falls through to upstream resolvers. forward . 1.1.1.1 1.0.0.1 9.9.9.9 { max_concurrent 1000 } - - # In-memory cache (TTL clamp 30s for both pos/neg). cache 30 - - # Operational plugins - health :8080 - prometheus :9153 errors log loop reload 10s } + +# Plain DNS — UDP/TCP :53. Health + metrics live here only (one binding). +. { + import common + health :8080 + prometheus :9153 +} + +# DNS-over-TLS — RFC 7858. Port 853 is the IANA-assigned DoT port. +tls://.:853 { + tls /etc/coredns/certs/cert.pem /etc/coredns/certs/key.pem + import common +} + +# DNS-over-HTTPS — RFC 8484. Default path is /dns-query. +# Clients: curl -H 'accept: application/dns-message' https://host:8443/dns-query?dns=... +https://.:443 { + tls /etc/coredns/certs/cert.pem /etc/coredns/certs/key.pem + import common +} diff --git a/Makefile b/Makefile index ef13611..99bb8d5 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ SHELL := /usr/bin/env bash COMPOSE := docker compose -.PHONY: help prep up down restart logs ps test reload clean +.PHONY: help prep certs up down restart logs ps test test-tls reload clean help: ## Show this help @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -10,7 +10,10 @@ help: ## Show this help prep: ## Re-inject SOA records into all zones (writes zones-prepared/) @./scripts/prepare-zones.sh -up: prep ## Start CoreDNS (prepares zones first) +certs: ## Generate self-signed cert for DoT/DoH (re-run with FORCE=1 to rotate) + @./scripts/generate-certs.sh + +up: prep certs ## Start CoreDNS (prepares zones + ensures certs exist first) $(COMPOSE) up -d @sleep 2 && $(COMPOSE) logs --tail=20 coredns @@ -29,11 +32,28 @@ logs: ## Tail CoreDNS logs ps: ## Show container status $(COMPOSE) ps -test: ## Smoke-test against a known zone (uses DNS_PORT from .env) - @. ./.env && echo "Querying acrazy.org @ 127.0.0.1:$$DNS_PORT" && \ +test: ## Smoke-test plain DNS (uses DNS_PORT from .env) + @. ./.env && echo "Querying acrazy.org @ 127.0.0.1:$$DNS_PORT (plain DNS)" && \ dig @127.0.0.1 -p $$DNS_PORT acrazy.org SOA +short && \ dig @127.0.0.1 -p $$DNS_PORT acrazy.org NS +short && \ dig @127.0.0.1 -p $$DNS_PORT or.acrazy.org A +short -clean: down ## Remove containers + prepared zones - rm -rf zones-prepared/*.zone +test-tls: ## Smoke-test DoT + DoH (pins self-signed cert via +tls-ca) + @. ./.env && \ + echo "=== DoT @ 127.0.0.1:$$DOT_PORT ===" && \ + dig @127.0.0.1 -p $$DOT_PORT +tls +tls-ca=certs/cert.pem \ + +tls-hostname=localhost acrazy.org SOA +short && \ + echo "" && \ + echo "=== DoH @ https://localhost:$$DOH_PORT/dns-query ===" && \ + dig @localhost -p $$DOH_PORT +https +tls-ca=certs/cert.pem \ + acrazy.org A +short && \ + echo "" && \ + echo "=== DoH via curl (raw wire-format) ===" && \ + curl -sk --cacert certs/cert.pem \ + -H 'accept: application/dns-message' \ + --data-binary @<(printf '\x00\x00\x01\x20\x00\x01\x00\x00\x00\x00\x00\x00\x06acrazy\x03org\x00\x00\x01\x00\x01') \ + -H 'content-type: application/dns-message' \ + "https://localhost:$$DOH_PORT/dns-query" | xxd | head -5 + +clean: down ## Remove containers + prepared zones + certs + rm -rf zones-prepared/*.zone certs/*.pem diff --git a/docker-compose.yml b/docker-compose.yml index ebc24b7..52211b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,14 @@ services: ports: - "${DNS_PORT}:53/udp" - "${DNS_PORT}:53/tcp" + - "${DOT_PORT}:853/tcp" + - "${DOH_PORT}:443/tcp" - "${METRICS_PORT}:9153/tcp" - "${HEALTH_PORT}:8080/tcp" volumes: - ./Corefile:/etc/coredns/Corefile:ro - ./zones-prepared:/zones:ro + - ./certs:/etc/coredns/certs:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"] interval: 30s diff --git a/scripts/generate-certs.sh b/scripts/generate-certs.sh new file mode 100755 index 0000000..566d566 --- /dev/null +++ b/scripts/generate-certs.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Generate a self-signed RSA cert for CoreDNS DoT/DoH (local dev only). +# Production deployments should mount real Let's Encrypt certs (e.g. from +# Caddy's shared volume) into certs/ instead. +# +# SANs cover the names a local resolver client is likely to use: +# - localhost (loopback by name) +# - 127.0.0.1 / ::1 (loopback by IP) +# - coredns (docker container DNS name) +# - dns.local (convenient pinning hostname) +set -euo pipefail + +CERT_DIR="${CERT_DIR:-certs}" +DAYS="${DAYS:-825}" # 825 is the historical macOS/Apple cap; safe ceiling + +mkdir -p "$CERT_DIR" + +if [[ -f "$CERT_DIR/cert.pem" && -f "$CERT_DIR/key.pem" ]]; then + # Skip regeneration if not expired and re-prep was not forced. + if [[ "${FORCE:-0}" != "1" ]]; then + # openssl returns 0 if cert is valid beyond the given window (24h here). + if openssl x509 -checkend 86400 -noout -in "$CERT_DIR/cert.pem" >/dev/null 2>&1; then + echo "Cert at $CERT_DIR/cert.pem still valid (>24h). Set FORCE=1 to regenerate." + exit 0 + fi + fi +fi + +# Single-shot self-signed cert with SANs. -addext requires openssl >= 1.1.1. +openssl req -x509 -newkey rsa:2048 -nodes -days "$DAYS" \ + -keyout "$CERT_DIR/key.pem" \ + -out "$CERT_DIR/cert.pem" \ + -subj "/CN=coredns-local" \ + -addext "subjectAltName=DNS:localhost,DNS:coredns,DNS:dns.local,IP:127.0.0.1,IP:::1" \ + >/dev/null 2>&1 + +# 0644 (not 0600) on the key so the CoreDNS container's `nonroot` user +# can read it via the bind mount. Acceptable for a local-dev self-signed +# cert whose private key never leaves this directory. For production +# certs, mount with explicit UID/GID via :ro,uid=65532 or use a tmpfs/ +# secret instead. +chmod 644 "$CERT_DIR/key.pem" +chmod 644 "$CERT_DIR/cert.pem" + +echo "Generated $CERT_DIR/{cert,key}.pem (valid ${DAYS} days)" +openssl x509 -in "$CERT_DIR/cert.pem" -noout -subject -issuer -dates \ + -ext subjectAltName 2>&1 | sed 's/^/ /'