diff --git a/hscontrol/app.go b/hscontrol/app.go index 5f0efa379..2b431b7e2 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -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() diff --git a/hscontrol/noise.go b/hscontrol/noise.go index e7bc998af..7df253399 100644 --- a/hscontrol/noise.go +++ b/hscontrol/noise.go @@ -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()} } diff --git a/hscontrol/noise_test.go b/hscontrol/noise_test.go index e40e10d23..dadde22ef 100644 --- a/hscontrol/noise_test.go +++ b/hscontrol/noise_test.go @@ -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) +} diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index b5badf4ff..329a57e56 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -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}) } diff --git a/hscontrol/servertest/setdns_test.go b/hscontrol/servertest/setdns_test.go new file mode 100644 index 000000000..7018b65b3 --- /dev/null +++ b/hscontrol/servertest/setdns_test.go @@ -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}) + }) +} diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index b9bb4a4f6..ae34927c1 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -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.