mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-27 16:56:49 +09:00
_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
213 lines
5.6 KiB
Go
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")
|
|
}
|