Files
headscale/hscontrol/dns/propagation_test.go
T
Kristoffer Dalby eef0d1fe47 dns: add acme-dns provider
_acme-challenge is CNAMEd once to an acme-dns registration, so headscale
needs no DNS credentials. Node certificates need one registration per
name; CNAME chains are followed and writes never evict a live value.

Updates #2137
2026-10-18 20:53:18 +02:00

213 lines
5.6 KiB
Go

package dns
import (
"context"
"net"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
mdns "github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// acmeZone stands in for an acme-dns server's zone.
const acmeZone = "acme.example.net."
// fakeNameserver answers authoritatively for testZone and acmeZone: ns1
// and ns2 are their nameservers, TXT answers come from txt and a name in
// cname is an alias.
type fakeNameserver struct {
mu sync.Mutex
txt map[string][]string
cname map[string]string
// hideFor makes the first n TXT queries miss, like a nameserver that
// has not caught up yet.
hideFor atomic.Int32
}
func (f *fakeNameserver) set(values ...string) {
f.mu.Lock()
defer f.mu.Unlock()
f.txt[testName] = values
}
func (f *fakeNameserver) ServeDNS(w mdns.ResponseWriter, r *mdns.Msg) {
m := new(mdns.Msg)
m.SetReply(r)
m.Authoritative = true
q := r.Question[0]
hdr := mdns.RR_Header{Name: q.Name, Class: mdns.ClassINET, Ttl: 60, Rrtype: q.Qtype}
f.mu.Lock()
target, alias := f.cname[q.Name]
f.mu.Unlock()
if alias {
hdr.Rrtype = mdns.TypeCNAME
m.Answer = []mdns.RR{&mdns.CNAME{Hdr: hdr, Target: target}}
_ = w.WriteMsg(m)
return
}
switch q.Qtype {
case mdns.TypeNS:
if q.Name != testZone && q.Name != acmeZone {
break
}
m.Answer = []mdns.RR{
&mdns.NS{Hdr: hdr, Ns: "ns1." + testZone},
&mdns.NS{Hdr: hdr, Ns: "ns2." + testZone},
}
case mdns.TypeA:
switch q.Name {
case "ns1." + testZone:
m.Answer = []mdns.RR{&mdns.A{Hdr: hdr, A: net.IPv4(127, 0, 0, 1)}}
case "ns2." + testZone:
m.Answer = []mdns.RR{&mdns.A{Hdr: hdr, A: net.IPv4(127, 0, 0, 2)}}
}
case mdns.TypeTXT:
if f.hideFor.Add(-1) >= 0 {
break
}
f.mu.Lock()
for _, v := range f.txt[q.Name] {
m.Answer = append(m.Answer, &mdns.TXT{Hdr: hdr, Txt: []string{v}})
}
f.mu.Unlock()
}
if len(m.Answer) == 0 {
m.Ns = []mdns.RR{&mdns.SOA{
Hdr: mdns.RR_Header{Name: testZone, Rrtype: mdns.TypeSOA, Class: mdns.ClassINET, Ttl: 60},
Ns: "ns1." + testZone,
Mbox: "hostmaster." + testZone,
Serial: 1,
Refresh: 60, Retry: 60, Expire: 60, Minttl: 60,
}}
}
_ = w.WriteMsg(m)
}
// startNameservers runs ns1 on 127.0.0.1 and ns2 on 127.0.0.2, both on the
// same port, and returns a checker using ns1 as its recursive resolver.
func startNameservers(t *testing.T) (txtChecker, *fakeNameserver, *fakeNameserver) {
t.Helper()
if runtime.GOOS != "linux" {
t.Skip("needs 127.0.0.2, which only Linux routes by default")
}
ns1 := &fakeNameserver{txt: map[string][]string{}, cname: map[string]string{}}
ns2 := &fakeNameserver{txt: map[string][]string{}, cname: map[string]string{}}
var lc net.ListenConfig
pc1, err := lc.ListenPacket(t.Context(), "udp", "127.0.0.1:0")
require.NoError(t, err)
_, port, err := net.SplitHostPort(pc1.LocalAddr().String())
require.NoError(t, err)
pc2, err := lc.ListenPacket(t.Context(), "udp", net.JoinHostPort("127.0.0.2", port))
require.NoError(t, err)
for pc, h := range map[net.PacketConn]mdns.Handler{pc1: ns1, pc2: ns2} {
srv := &mdns.Server{PacketConn: pc, Handler: h}
go func() { _ = srv.ActivateAndServe() }()
t.Cleanup(func() { _ = srv.Shutdown() })
}
c := newTXTChecker([]string{net.JoinHostPort("127.0.0.1", port)}, 5*time.Second)
c.interval = 10 * time.Millisecond
c.port = port
return c, ns1, ns2
}
func TestTXTCheckerAllNameservers(t *testing.T) {
c, ns1, ns2 := startNameservers(t)
ctx := context.Background()
ns1.set("other", "value")
ns2.set("value")
require.NoError(t, c.wait(ctx, testName, "value"))
// ns2 never gets the value: waiting for ns1 alone would let the CA
// hit ns2 and fail the challenge.
ns1.set("value-2")
c.timeout = 200 * time.Millisecond
err := c.wait(ctx, testName, "value-2")
require.ErrorIs(t, err, ErrNotPropagated)
require.ErrorContains(t, err, "ns2."+testZone)
assert.NotContains(t, err.Error(), "ns1.")
}
func TestTXTCheckerWaitsForLaggingNameserver(t *testing.T) {
c, ns1, ns2 := startNameservers(t)
ns1.set("value")
ns2.set("value")
ns2.hideFor.Store(3)
require.NoError(t, c.wait(context.Background(), testName, "value"))
assert.Negative(t, ns2.hideFor.Load(), "ns2 was asked again after it missed")
}
// A challenge delegated by CNAME, as to acme-dns, is only served by the
// zone of the chain's last name; the names before it answer with a CNAME.
// The intermediate name sits in testZone, whose nameservers would answer
// without ever serving the value.
func TestTXTCheckerFollowsCNAMEChain(t *testing.T) {
c, ns1, ns2 := startNameservers(t)
relay := "relay." + testZone
target := "f0e3c7." + acmeZone
for _, ns := range []*fakeNameserver{ns1, ns2} {
ns.mu.Lock()
ns.cname[testName] = relay
ns.cname[relay] = target
ns.txt[target] = []string{"value"}
ns.mu.Unlock()
}
require.NoError(t, c.wait(context.Background(), testName, "value"))
c.timeout = 200 * time.Millisecond
err := c.wait(context.Background(), testName, "other")
require.ErrorIs(t, err, ErrNotPropagated)
require.ErrorContains(t, err, target)
// A loop fails at once instead of waiting out the timeout.
for _, ns := range []*fakeNameserver{ns1, ns2} {
ns.mu.Lock()
ns.cname[relay] = testName
ns.mu.Unlock()
}
err = c.wait(context.Background(), testName, "value")
require.ErrorIs(t, err, errCNAMEChain)
}
func TestTXTCheckerErrorNamesResolvers(t *testing.T) {
c := newTXTChecker([]string{"1.1.1.1:53", "8.8.8.8:53"}, time.Second)
err := c.via(&net.DNSError{Err: "no such host", Name: "ts.example.com.", Server: "127.0.0.53:53"})
assert.EqualError(t, err, "lookup ts.example.com. on 1.1.1.1:53, 8.8.8.8:53: no such host")
}