mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-22 14:26:50 +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.
|
||||
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
@@ -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()}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
|
||||
Reference in new issue
Block a user