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
This commit is contained in:
Kristoffer Dalby committed 2026-10-18 20:53:18 +02:00
1 parent 3f6e54cbd5
commit f16d5e1857
6 files changed
+567 -7

No files matched your search

+17
View File
@@ -98,6 +98,9 @@ type Headscale struct {
// router skips the mount and r.RemoteAddr stays as the TCP peer.
realIPMiddleware func(http.Handler) http.Handler
// challenges is nil unless dns.certificates is enabled.
challenges *dns.Challenges
// Things that generate changes
extraRecordMan *dns.ExtraRecordsMan
authProvider AuthProvider
@@ -139,6 +142,15 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
state: s,
}
if cfg.DNSConfig.Certificates.Enabled {
app.challenges = dns.NewChallenges(
cfg.DNSChallenge.Provider,
cfg.DNSConfig.Certificates.Zone,
cfg.DNSChallenge.Resolvers,
cfg.DNSChallenge.PropagationTimeout,
)
}
if len(cfg.TrustedProxies) > 0 {
app.realIPMiddleware, err = trustedProxyRealIP(cfg.TrustedProxies)
if err != nil {
@@ -935,6 +947,11 @@ func (h *Headscale) Serve() error {
tlsBundle.ACMEListener.Close()
}
if h.challenges != nil {
info("removing pending ACME challenge records")
h.challenges.Close() //nolint:contextcheck // each delete has its own bounded context
}
info("closing batcher")
h.mapBatcher.Close()
+116 -4
View File
@@ -9,12 +9,14 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/metrics"
"github.com/juanfont/headscale/hscontrol/capver"
"github.com/juanfont/headscale/hscontrol/dns"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -174,10 +176,7 @@ func (h *Headscale) NoiseUpgradeHandler(
// https://github.com/tailscale/tailscale/blob/dfba01ca9bd8c4df02c3c32f400d9aeb897c5fc7/cmd/tailscale/cli/debug.go#L1138
r.Get("/whoami", ns.NotImplementedHandler)
// client sends a [tailcfg.SetDNSRequest] to this endpoints and expect
// the server to create or update this DNS record "somewhere".
// It is typically a TXT record for an ACME challenge.
r.Post("/set-dns", ns.NotImplementedHandler)
r.Post("/set-dns", ns.SetDNSHandler)
// A patch of [tailcfg.SetDeviceAttributesRequest] to update device attributes.
// We currently do not support device attributes.
@@ -778,6 +777,119 @@ func (ns *noiseServer) PollNetMapHandler(
}
}
// challengeValueRE matches an ACME DNS-01 TXT value: the unpadded
// base64url SHA-256 of the key authorization (RFC 8555 section 8.4).
var challengeValueRE = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
// SetDNSHandler publishes the TXT record of an ACME DNS-01 challenge for
// `tailscale cert`. The client runs ACME itself and asks the CA to
// validate as soon as this returns, so it only answers 200 once the
// record is visible. The client never asks for removal; [dns.Challenges]
// removes the record later.
func (ns *noiseServer) SetDNSHandler(writer http.ResponseWriter, req *http.Request) {
challenges := ns.headscale.challenges
if challenges == nil {
http.Error(writer, "HTTPS certificates are not enabled on this headscale server", http.StatusNotImplemented)
return
}
var setDNS tailcfg.SetDNSRequest
err := json.NewDecoder(req.Body).Decode(&setDNS)
if err != nil {
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid set-dns request", err))
return
}
if rejectUnsupported(writer, setDNS.Version, ns.machineKey, setDNS.NodeKey) {
return
}
nv, ok := ns.headscale.state.GetNodeByNodeKey(setDNS.NodeKey)
if !ok {
httpError(writer, NewHTTPError(http.StatusNotFound, "node not found", errNodeNotInStore))
return
}
err = machineKeyMismatch(nv, ns.machineKey)
if err != nil {
httpError(writer, err)
return
}
name, err := validateSetDNS(setDNS, nv, ns.headscale.cfg)
if err != nil {
httpError(writer, err)
return
}
err = challenges.Present(req.Context(), nv.ID().Uint64(), name, setDNS.Value)
if err != nil {
switch {
case errors.Is(err, dns.ErrTooManyChallenges):
err = NewHTTPError(http.StatusTooManyRequests, "too many pending certificate challenges, try again later", err)
case errors.Is(err, dns.ErrChallengesClosed):
err = NewHTTPError(http.StatusServiceUnavailable, "headscale is shutting down, try again later", err)
case errors.Is(err, dns.ErrChallengeProvider):
err = NewHTTPError(http.StatusBadGateway, "DNS provider could not create the record, see the headscale log", err)
default:
err = NewHTTPError(http.StatusGatewayTimeout, "DNS record not visible on all nameservers yet, try again", err)
}
httpError(writer, err)
return
}
log.Debug().EmbedObject(nv).Str("name", name).Msg("ACME challenge ready for validation")
writer.Header().Set("Content-Type", "application/json")
// The client fails on an empty body, so the empty response is encoded.
err = json.NewEncoder(writer).Encode(tailcfg.SetDNSResponse{})
if err != nil {
log.Error().Caller().Err(err).Msg("writing set-dns response")
}
}
// validateSetDNS lets a node publish only the challenge of its own
// certificate name, and returns that name. The Noise endpoint accepts any
// machine key and the client's LocalAPI forwards any name and value, so
// this is what stops a node from writing arbitrary records into the
// operator's zone.
func validateSetDNS(req tailcfg.SetDNSRequest, nv types.NodeView, cfg *types.Config) (string, error) {
if nv.IsExpired() {
return "", NewHTTPError(http.StatusForbidden, "node is expired", nil)
}
domain, ok := nv.CertDomain(cfg)
if !ok {
return "", NewHTTPError(http.StatusForbidden, "node has no certificate name", nil)
}
if req.Type != "TXT" {
return "", NewHTTPError(http.StatusBadRequest, "only TXT records are supported", nil)
}
// The client sends the name built from CertDomains, so it matches
// exactly; folding case would let lookalike Unicode letters match.
name := types.ACMEChallengePrefix + domain
if req.Name != name {
return "", NewHTTPError(http.StatusForbidden, "name is not the ACME challenge of this node's certificate", nil)
}
if !challengeValueRE.MatchString(req.Value) {
return "", NewHTTPError(http.StatusBadRequest, "value is not an ACME DNS-01 challenge", nil)
}
return name, nil
}
func regErr(err error) *tailcfg.RegisterResponse {
return &tailcfg.RegisterResponse{Error: err.Error()}
}
+329
View File
@@ -3,6 +3,8 @@ package hscontrol
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
@@ -20,6 +22,8 @@ import (
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/capver"
"github.com/juanfont/headscale/hscontrol/dns"
"github.com/juanfont/headscale/hscontrol/dns/dnstest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/rs/zerolog"
@@ -1344,3 +1348,328 @@ func TestPollNetMapHandler_ForeignMachineKeyStillRejected(t *testing.T) {
assert.Equal(t, http.StatusNotFound, rec.Code, "body=%q", rec.Body.String())
}
const setDNSZone = "ts.example.com"
// challengeValue returns a well-formed ACME DNS-01 TXT value.
func challengeValue(seed string) string {
sum := sha256.Sum256([]byte(seed))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// setDNSTestApp returns an app with node certificates enabled and an
// in-memory DNS provider that records every call.
func setDNSTestApp(t *testing.T) (*Headscale, *dnstest.Provider) {
t.Helper()
app := createTestApp(t)
app.cfg.BaseDomain = setDNSZone
app.cfg.DNSConfig.Certificates = types.CertificatesConfig{Enabled: true, Zone: setDNSZone}
provider := &dnstest.Provider{}
app.challenges = dns.NewChallenges(provider, setDNSZone, nil, 0)
t.Cleanup(app.challenges.Close)
return app, provider
}
func putCertNode(t *testing.T, app *Headscale, user *types.User, name string, modify ...func(*types.Node)) *types.Node {
t.Helper()
node := app.state.CreateNodeForTest(user, name)
node.GivenName = name
for _, m := range modify {
m(node)
}
app.state.PutNodeInStoreForTest(*node)
return node
}
func setDNS(t *testing.T, ns *noiseServer, req tailcfg.SetDNSRequest) *httptest.ResponseRecorder {
t.Helper()
return setDNSWithContext(t.Context(), t, ns, req)
}
func setDNSWithContext(
ctx context.Context,
t *testing.T,
ns *noiseServer,
req tailcfg.SetDNSRequest,
) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(req)
require.NoError(t, err)
rec := httptest.NewRecorder()
ns.SetDNSHandler(rec, httptest.NewRequestWithContext(
ctx, http.MethodPost, "/machine/set-dns", bytes.NewReader(body)))
return rec
}
// TestSetDNSHandler pins who may publish which record. The Noise endpoint
// accepts any machine key and the client's LocalAPI forwards any name and
// value, so every rejection must leave the operator's zone untouched.
func TestSetDNSHandler(t *testing.T) {
t.Parallel()
value := challengeValue("laptop")
tests := []struct {
name string
mutate func(req *tailcfg.SetDNSRequest, mkey *key.MachinePublic)
givenName string
expired bool
wantCode int
wantValue bool
}{
{
name: "own challenge",
wantCode: http.StatusOK,
wantValue: true,
},
{
name: "own name in another case",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Name = "_acme-challenge.Laptop." + setDNSZone
},
wantCode: http.StatusForbidden,
},
{
name: "other node's name",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Name = "_acme-challenge.desktop." + setDNSZone
},
wantCode: http.StatusForbidden,
},
{
name: "zone apex",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Name = "_acme-challenge." + setDNSZone
},
wantCode: http.StatusForbidden,
},
{
name: "subdomain of own name",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Name = "_acme-challenge.www.laptop." + setDNSZone
},
wantCode: http.StatusForbidden,
},
{
name: "own name below another zone",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Name = "_acme-challenge.laptop." + setDNSZone + ".evil.com"
},
wantCode: http.StatusForbidden,
},
{
name: "own name without challenge label",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Name = "laptop." + setDNSZone
},
wantCode: http.StatusForbidden,
},
{
name: "not TXT",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Type = "CNAME"
},
wantCode: http.StatusBadRequest,
},
{
name: "value is not a challenge",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Value = "v=spf1 include:evil.com ~all"
},
wantCode: http.StatusBadRequest,
},
{
name: "empty value",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Value = ""
},
wantCode: http.StatusBadRequest,
},
{
name: "rogue machine key",
mutate: func(_ *tailcfg.SetDNSRequest, mkey *key.MachinePublic) {
*mkey = key.NewMachine().Public()
},
wantCode: http.StatusUnauthorized,
},
{
name: "unknown node key",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.NodeKey = key.NewNode().Public()
},
wantCode: http.StatusNotFound,
},
{
name: "unsupported client",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Version = 1
},
wantCode: http.StatusBadRequest,
},
{
name: "expired node",
expired: true,
wantCode: http.StatusForbidden,
},
{
// Case folding would match U+017F against 's'.
name: "unicode lookalike name",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Name = "_acme-challenge.laptop.t\u017f.example.com"
},
wantCode: http.StatusForbidden,
},
{
// Names from before renames were lowercased get no certificate.
name: "node without certificate name",
givenName: "Laptop",
mutate: func(req *tailcfg.SetDNSRequest, _ *key.MachinePublic) {
req.Name = "_acme-challenge.Laptop." + setDNSZone
},
wantCode: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
app, provider := setDNSTestApp(t)
user := app.state.CreateUserForTest("set-dns-user")
laptop := putCertNode(t, app, user, "laptop", func(n *types.Node) {
if tt.expired {
n.Expiry = new(time.Now().Add(-time.Hour))
}
if tt.givenName != "" {
n.GivenName = tt.givenName
}
})
putCertNode(t, app, user, "desktop")
req := tailcfg.SetDNSRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: laptop.NodeKey,
Name: "_acme-challenge.laptop." + setDNSZone,
Type: "TXT",
Value: value,
}
mkey := laptop.MachineKey
if tt.mutate != nil {
tt.mutate(&req, &mkey)
}
rec := setDNS(t, &noiseServer{headscale: app, machineKey: mkey}, req)
require.Equal(t, tt.wantCode, rec.Code, rec.Body.String())
if !tt.wantValue {
assert.Empty(t, provider.Calls(), "rejected request must not reach the DNS provider")
return
}
var resp tailcfg.SetDNSResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp), "client needs a JSON body")
assert.Equal(t, []string{value}, provider.Values("_acme-challenge.laptop."+setDNSZone+"."))
})
}
}
// TestSetDNSHandlerProviderErrors pins the status codes the client shows
// when publishing fails after validation passed.
func TestSetDNSHandlerProviderErrors(t *testing.T) {
t.Parallel()
app, provider := setDNSTestApp(t)
user := app.state.CreateUserForTest("set-dns-errors-user")
laptop := putCertNode(t, app, user, "laptop")
ns := &noiseServer{headscale: app, machineKey: laptop.MachineKey}
req := func(seed string) tailcfg.SetDNSRequest {
return tailcfg.SetDNSRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: laptop.NodeKey,
Name: "_acme-challenge.laptop." + setDNSZone,
Type: "TXT",
Value: challengeValue(seed),
}
}
provider.SetError(errSetDNSProviderDown)
assert.Equal(t, http.StatusBadGateway, setDNS(t, ns, req("down")).Code)
provider.SetError(nil)
require.Equal(t, http.StatusOK, setDNS(t, ns, req("0")).Code)
// Later values on the name wait out the CA's cache of the first; a
// client that gives up meanwhile still fills the node's slots.
gone, cancel := context.WithCancel(t.Context())
cancel()
for i := 1; i < 4; i++ {
require.Equal(t, http.StatusGatewayTimeout, setDNSWithContext(gone, t, ns, req(strconv.Itoa(i))).Code)
}
assert.Equal(t, http.StatusTooManyRequests, setDNS(t, ns, req("one-too-many")).Code)
// Renaming the node does not free its slots.
laptop.GivenName = "renamed"
app.state.PutNodeInStoreForTest(*laptop)
renamed := req("renamed")
renamed.Name = "_acme-challenge.renamed." + setDNSZone
assert.Equal(t, http.StatusTooManyRequests, setDNS(t, ns, renamed).Code)
app.challenges.Close()
assert.Equal(t, http.StatusServiceUnavailable, setDNS(t, ns, renamed).Code)
}
var errSetDNSProviderDown = errors.New("provider down")
func TestSetDNSHandlerBadBody(t *testing.T) {
t.Parallel()
app, provider := setDNSTestApp(t)
user := app.state.CreateUserForTest("set-dns-body-user")
laptop := putCertNode(t, app, user, "laptop")
rec := httptest.NewRecorder()
(&noiseServer{headscale: app, machineKey: laptop.MachineKey}).SetDNSHandler(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "/machine/set-dns", strings.NewReader("{not json")))
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Empty(t, provider.Calls())
}
// TestSetDNSHandlerDisabled pins that the endpoint stays unimplemented
// unless node certificates are enabled.
func TestSetDNSHandlerDisabled(t *testing.T) {
t.Parallel()
app := createTestApp(t)
user := app.state.CreateUserForTest("set-dns-off-user")
laptop := putCertNode(t, app, user, "laptop")
rec := setDNS(t, &noiseServer{headscale: app, machineKey: laptop.MachineKey}, tailcfg.SetDNSRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: laptop.NodeKey,
Name: "_acme-challenge.laptop." + setDNSZone,
Type: "TXT",
Value: challengeValue("laptop"),
})
assert.Equal(t, http.StatusNotImplemented, rec.Code)
}
+14
View File
@@ -13,6 +13,7 @@ import (
"time"
hscontrol "github.com/juanfont/headscale/hscontrol"
hsdns "github.com/juanfont/headscale/hscontrol/dns"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/net/memnet"
@@ -49,6 +50,7 @@ type serverConfig struct {
realListener bool
magicDNSDomain string
dnsResolvers []string
certProvider hsdns.Provider
}
func defaultServerConfig() *serverConfig {
@@ -111,6 +113,13 @@ func WithMagicDNS(domain string) ServerOption {
return func(c *serverConfig) { c.magicDNSDomain = domain }
}
// WithCertificates enables node certificates (dns.certificates) with
// provider publishing the challenges. It needs [WithMagicDNS]; the
// propagation check is off because the provider is not real DNS.
func WithCertificates(provider hsdns.Provider) ServerOption {
return func(c *serverConfig) { c.certProvider = provider }
}
// WithDNSResolvers sets the global DNS resolvers, so map responses carry a
// [tailcfg.DNSConfig].
func WithDNSResolvers(addrs ...string) ServerOption {
@@ -180,6 +189,11 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer {
cfg.TailcfgDNSConfig.Domains = []string{sc.magicDNSDomain}
}
if sc.certProvider != nil {
cfg.DNSConfig.Certificates = types.CertificatesConfig{Enabled: true, Zone: cfg.BaseDomain}
cfg.DNSChallenge.Provider = sc.certProvider
}
for _, addr := range sc.dnsResolvers {
cfg.TailcfgDNSConfig.Resolvers = append(cfg.TailcfgDNSConfig.Resolvers, &dnstype.Resolver{Addr: addr})
}
+88
View File
@@ -0,0 +1,88 @@
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})
})
}
+3 -3
View File
@@ -802,7 +802,7 @@ func (nv NodeView) CertDomain(cfg *Config) (string, bool) {
// Names from before renames were lowercased can differ from another
// node's name by case only, while the challenge record cannot.
if domain != strings.ToLower(domain) || len(acmeChallengePrefix+domain) > 253 {
if domain != strings.ToLower(domain) || len(ACMEChallengePrefix+domain) > 253 {
return "", false
}
@@ -817,9 +817,9 @@ func (nv NodeView) CertDomain(cfg *Config) (string, bool) {
return domain, true
}
// acmeChallengePrefix is prepended to a certificate name to form the TXT
// ACMEChallengePrefix is prepended to a certificate name to form the TXT
// record name of its ACME DNS-01 challenge.
const acmeChallengePrefix = "_acme-challenge."
const ACMEChallengePrefix = "_acme-challenge."
// Owner returns the owner for display purposes.
// For tagged nodes, returns [TaggedDevices]. For user-owned nodes, returns the user.