mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-24 15:26:49 +09:00
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:
1 parent
3f6e54cbd5
commit
f16d5e1857
6 files changed
+567
-7
No files matched your search
@@ -98,6 +98,9 @@ type Headscale struct {
|
|||||||
// router skips the mount and r.RemoteAddr stays as the TCP peer.
|
// router skips the mount and r.RemoteAddr stays as the TCP peer.
|
||||||
realIPMiddleware func(http.Handler) http.Handler
|
realIPMiddleware func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
// challenges is nil unless dns.certificates is enabled.
|
||||||
|
challenges *dns.Challenges
|
||||||
|
|
||||||
// Things that generate changes
|
// Things that generate changes
|
||||||
extraRecordMan *dns.ExtraRecordsMan
|
extraRecordMan *dns.ExtraRecordsMan
|
||||||
authProvider AuthProvider
|
authProvider AuthProvider
|
||||||
@@ -139,6 +142,15 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
|
|||||||
state: s,
|
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 {
|
if len(cfg.TrustedProxies) > 0 {
|
||||||
app.realIPMiddleware, err = trustedProxyRealIP(cfg.TrustedProxies)
|
app.realIPMiddleware, err = trustedProxyRealIP(cfg.TrustedProxies)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -935,6 +947,11 @@ func (h *Headscale) Serve() error {
|
|||||||
tlsBundle.ACMEListener.Close()
|
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")
|
info("closing batcher")
|
||||||
h.mapBatcher.Close()
|
h.mapBatcher.Close()
|
||||||
|
|
||||||
|
|||||||
+116
-4
@@ -9,12 +9,14 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/metrics"
|
"github.com/go-chi/metrics"
|
||||||
"github.com/juanfont/headscale/hscontrol/capver"
|
"github.com/juanfont/headscale/hscontrol/capver"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/dns"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"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
|
// https://github.com/tailscale/tailscale/blob/dfba01ca9bd8c4df02c3c32f400d9aeb897c5fc7/cmd/tailscale/cli/debug.go#L1138
|
||||||
r.Get("/whoami", ns.NotImplementedHandler)
|
r.Get("/whoami", ns.NotImplementedHandler)
|
||||||
|
|
||||||
// client sends a [tailcfg.SetDNSRequest] to this endpoints and expect
|
r.Post("/set-dns", ns.SetDNSHandler)
|
||||||
// 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)
|
|
||||||
|
|
||||||
// A patch of [tailcfg.SetDeviceAttributesRequest] to update device attributes.
|
// A patch of [tailcfg.SetDeviceAttributesRequest] to update device attributes.
|
||||||
// We currently do not support 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 {
|
func regErr(err error) *tailcfg.RegisterResponse {
|
||||||
return &tailcfg.RegisterResponse{Error: err.Error()}
|
return &tailcfg.RegisterResponse{Error: err.Error()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package hscontrol
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -20,6 +22,8 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/juanfont/headscale/hscontrol/capver"
|
"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/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"github.com/rs/zerolog"
|
"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())
|
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)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
hscontrol "github.com/juanfont/headscale/hscontrol"
|
hscontrol "github.com/juanfont/headscale/hscontrol"
|
||||||
|
hsdns "github.com/juanfont/headscale/hscontrol/dns"
|
||||||
"github.com/juanfont/headscale/hscontrol/state"
|
"github.com/juanfont/headscale/hscontrol/state"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"tailscale.com/net/memnet"
|
"tailscale.com/net/memnet"
|
||||||
@@ -49,6 +50,7 @@ type serverConfig struct {
|
|||||||
realListener bool
|
realListener bool
|
||||||
magicDNSDomain string
|
magicDNSDomain string
|
||||||
dnsResolvers []string
|
dnsResolvers []string
|
||||||
|
certProvider hsdns.Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultServerConfig() *serverConfig {
|
func defaultServerConfig() *serverConfig {
|
||||||
@@ -111,6 +113,13 @@ func WithMagicDNS(domain string) ServerOption {
|
|||||||
return func(c *serverConfig) { c.magicDNSDomain = domain }
|
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
|
// WithDNSResolvers sets the global DNS resolvers, so map responses carry a
|
||||||
// [tailcfg.DNSConfig].
|
// [tailcfg.DNSConfig].
|
||||||
func WithDNSResolvers(addrs ...string) ServerOption {
|
func WithDNSResolvers(addrs ...string) ServerOption {
|
||||||
@@ -180,6 +189,11 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer {
|
|||||||
cfg.TailcfgDNSConfig.Domains = []string{sc.magicDNSDomain}
|
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 {
|
for _, addr := range sc.dnsResolvers {
|
||||||
cfg.TailcfgDNSConfig.Resolvers = append(cfg.TailcfgDNSConfig.Resolvers, &dnstype.Resolver{Addr: addr})
|
cfg.TailcfgDNSConfig.Resolvers = append(cfg.TailcfgDNSConfig.Resolvers, &dnstype.Resolver{Addr: addr})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -802,7 +802,7 @@ func (nv NodeView) CertDomain(cfg *Config) (string, bool) {
|
|||||||
|
|
||||||
// Names from before renames were lowercased can differ from another
|
// Names from before renames were lowercased can differ from another
|
||||||
// node's name by case only, while the challenge record cannot.
|
// 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
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,9 +817,9 @@ func (nv NodeView) CertDomain(cfg *Config) (string, bool) {
|
|||||||
return domain, true
|
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.
|
// record name of its ACME DNS-01 challenge.
|
||||||
const acmeChallengePrefix = "_acme-challenge."
|
const ACMEChallengePrefix = "_acme-challenge."
|
||||||
|
|
||||||
// Owner returns the owner for display purposes.
|
// Owner returns the owner for display purposes.
|
||||||
// For tagged nodes, returns [TaggedDevices]. For user-owned nodes, returns the user.
|
// For tagged nodes, returns [TaggedDevices]. For user-owned nodes, returns the user.
|
||||||
|
|||||||
Reference in new issue
Block a user