mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-27 00:36:49 +09:00
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
89 lines
2.8 KiB
Go
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})
|
|
})
|
|
}
|