Files
headscale/hscontrol/servertest/setdns_test.go
T
Kristoffer Dalby f16d5e1857 noise: implement /machine/set-dns
A node may publish only its own _acme-challenge TXT value; any other
request is rejected before the provider is called. Answers once all
authoritative nameservers serve the value; 503 while shutting down.

Fixes #2137
Fixes #1921
Fixes #2527
2026-10-18 20:53:18 +02:00

89 lines
2.8 KiB
Go

package servertest_test
import (
"crypto/sha256"
"encoding/base64"
"slices"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/dns/dnstest"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/netmap"
)
// TestSetDNS drives the real client's SetDNS over Noise, the call
// `tailscale cert` makes for each ACME DNS-01 challenge.
func TestSetDNS(t *testing.T) {
t.Parallel()
const domain = "ts.example.com"
provider := &dnstest.Provider{}
srv := servertest.NewServer(t,
servertest.WithMagicDNS(domain),
servertest.WithCertificates(provider),
)
user := srv.CreateUser(t, "cert-user")
laptop := servertest.NewClient(t, srv, "laptop", servertest.WithUser(user))
desktop := servertest.NewClient(t, srv, "desktop", servertest.WithUser(user))
// Without the name in CertDomains the client refuses to run ACME, and
// without the https cap `tailscale serve` refuses HTTPS.
laptop.WaitForCondition(t, "cert domain and https cap", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil &&
slices.Equal(nm.DNS.CertDomains, []string{"laptop." + domain}) &&
hasCap(nm, nodecap.HTTPS)
})
value := func(seed string) string {
sum := sha256.Sum256([]byte(seed))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
nodeKey := func(c *servertest.TestClient) tailcfg.SetDNSRequest {
return tailcfg.SetDNSRequest{
NodeKey: c.Direct().GetPersist().PrivateNodeKey().Public(),
Type: "TXT",
}
}
req := nodeKey(laptop)
req.Name = "_acme-challenge.laptop." + domain
req.Value = value("laptop")
require.NoError(t, laptop.Direct().SetDNS(t.Context(), &req))
assert.Equal(t, []string{value("laptop")}, provider.Values(req.Name+"."))
// desktop asks for laptop's challenge name with its own key.
req = nodeKey(desktop)
req.Name = "_acme-challenge.laptop." + domain
req.Value = value("desktop")
require.ErrorContains(t, desktop.Direct().SetDNS(t.Context(), &req), "403")
// desktop presents laptop's node key on its own Noise session.
req = nodeKey(laptop)
req.Name = "_acme-challenge.laptop." + domain
req.Value = value("forged")
require.ErrorContains(t, desktop.Direct().SetDNS(t.Context(), &req), "401")
assert.Len(t, provider.Calls(), 1, "rejected requests must not reach the DNS provider")
// The certificate name follows a rename, lowercased; the client only
// runs ACME for a name it finds in CertDomains.
_, renameChange, err := srv.State().RenameNode(findNodeID(t, srv, "laptop"), "NoteBook")
require.NoError(t, err)
srv.App.Change(renameChange)
laptop.WaitForCondition(t, "cert domain follows rename", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil && slices.Equal(nm.DNS.CertDomains, []string{"notebook." + domain})
})
}