mapper: send CertDomains and https cap when certificates are enabled

Both gate tailscale cert and serve HTTPS on the client; httpsEnabled
follows, as in Tailscale. No certificate when an operator extra record
sits at or below the name: a wildcard would cover it.

Updates #2137
Updates #1921
This commit is contained in:
Kristoffer Dalby committed 2026-10-18 20:53:18 +02:00
1 parent a928125afa
commit 3f6e54cbd5
7 files changed
+190 -15

No files matched your search

+4 -3
View File
@@ -70,9 +70,10 @@ func registerSettings(api huma.API, b Backend) {
return &settingsOutput{Body: TailnetSettings{
// File-mode policy is genuinely externally managed (read-only via API).
ACLsExternallyManagedOn: cfg.Policy.Mode == types.PolicyModeFile,
DevicesKeyDurationDays: int(cfg.Node.Expiry / (24 * time.Hour)),
HTTPSEnabled: cfg.TLS.CertPath != "" || cfg.TLS.LetsEncrypt.Hostname != "",
ACLsExternallyManagedOn: cfg.Policy.Mode == types.PolicyModeFile,
DevicesKeyDurationDays: int(cfg.Node.Expiry / (24 * time.Hour)),
// Tailscale's toggle for node certificates, not headscale's own TLS.
HTTPSEnabled: cfg.DNSConfig.Certificates.Enabled,
UsersRoleAllowedToJoinExternalTailnets: "none",
}}, nil
})
+12 -10
View File
@@ -54,16 +54,17 @@ func TestAPIv2SettingsComputedFields(t *testing.T) {
cfg: types.Config{},
},
{
name: "https via cert path",
cfg: types.Config{TLS: types.TLSConfig{CertPath: "/x/cert.pem"}},
name: "https via node certificates",
cfg: types.Config{
DNSConfig: types.DNSConfig{Certificates: types.CertificatesConfig{Enabled: true}},
},
wantHTTPS: true,
},
{
name: "https via letsencrypt",
cfg: types.Config{
TLS: types.TLSConfig{LetsEncrypt: types.LetsEncryptConfig{Hostname: "hs.example.com"}},
},
wantHTTPS: true,
// httpsEnabled is the node certificate toggle; the server's
// own TLS does not let nodes get certificates.
name: "server tls is not https",
cfg: types.Config{TLS: types.TLSConfig{CertPath: "/x/cert.pem"}},
},
{
name: "key duration 7d",
@@ -107,9 +108,10 @@ func TestAPIv2SettingsComputedFields(t *testing.T) {
// even with every config knob set, they must not pick up signal.
func TestAPIv2SettingsConstantOffFields(t *testing.T) {
cfg := types.Config{
TLS: types.TLSConfig{CertPath: "/x/cert.pem"},
Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour},
Policy: types.PolicyConfig{Mode: types.PolicyModeFile},
TLS: types.TLSConfig{CertPath: "/x/cert.pem"},
DNSConfig: types.DNSConfig{Certificates: types.CertificatesConfig{Enabled: true}},
Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour},
Policy: types.PolicyConfig{Mode: types.PolicyModeFile},
}
s := getSettings(t, settingsAPIWithConfig(t, &cfg))
+4
View File
@@ -147,6 +147,10 @@ func generateDNSConfig(
}
}
if domain, ok := node.CertDomain(cfg); ok {
dnsConfig.CertDomains = []string{domain}
}
return dnsConfig
}
+90
View File
@@ -1063,3 +1063,93 @@ func TestGenerateMapResponseDebugOnlyOnFullMaps(t *testing.T) {
}
}
}
// TestCertDomains pins the name sent in DNSConfig.CertDomains: the
// client only runs `tailscale cert` for a name it finds there exactly,
// compared against its own MagicDNS name without the trailing dot.
func TestCertDomains(t *testing.T) {
enabled := types.DNSConfig{Certificates: types.CertificatesConfig{Enabled: true}}
tests := []struct {
name string
node *types.Node
dns types.DNSConfig
baseDomain string
want []string
}{
{
name: "user owned",
node: &types.Node{GivenName: "laptop", UserID: new(uint(1))},
dns: enabled,
want: []string{"laptop.ts.example.com"},
},
{
name: "tagged",
node: &types.Node{GivenName: "server", Tags: []string{"tag:server"}},
dns: enabled,
want: []string{"server.ts.example.com"},
},
{
name: "disabled",
node: &types.Node{GivenName: "laptop"},
},
{
// The operator gave this name, in another case, to a service; a
// node naming itself grafana must not get a certificate for it.
name: "extra record name",
node: &types.Node{GivenName: "grafana"},
dns: enabled,
},
{
// A wildcard order for *.svc shares its challenge record with
// svc, so svc must not get one covering grafana.svc.
name: "extra record below name",
node: &types.Node{GivenName: "svc"},
dns: enabled,
},
{
name: "label suffix of extra record",
node: &types.Node{GivenName: "fana"},
dns: enabled,
want: []string{"fana.ts.example.com"},
},
{
// Renames are lowercased now; an older mixed-case name could
// share its challenge record with another node's name.
name: "mixed case name",
node: &types.Node{GivenName: "Laptop"},
dns: enabled,
},
{
name: "challenge name too long",
node: &types.Node{GivenName: strings.Repeat("a", 63)},
dns: enabled,
baseDomain: strings.Repeat("b.", 87) + "example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseDomain := tt.baseDomain
if baseDomain == "" {
baseDomain = "ts.example.com"
}
cfg := &types.Config{
BaseDomain: baseDomain,
DNSConfig: tt.dns,
TailcfgDNSConfig: &tailcfg.DNSConfig{
Proxied: true,
ExtraRecords: []tailcfg.DNSRecord{
{Name: "Grafana.ts.example.com", Type: "A", Value: "100.64.0.3"},
{Name: "grafana.svc.ts.example.com.", Type: "A", Value: "100.64.0.4"},
},
},
}
got := generateDNSConfig(cfg, tt.node.View(), nil)
assert.Equal(t, tt.want, got.CertDomains)
assert.Nil(t, cfg.TailcfgDNSConfig.CertDomains, "global DNS config must stay untouched")
})
}
}
+17 -2
View File
@@ -248,8 +248,9 @@ func TestTailNode(t *testing.T) {
}
// TestTailNodeBaselineGates focuses on the cfg-driven baseline cap
// emission: cfg.Taildrop.Enabled gates [tailcfg.CapabilityFileSharing]
// and cfg.AutoUpdate.Enabled controls the value of
// emission: cfg.Taildrop.Enabled gates [tailcfg.CapabilityFileSharing],
// cfg.DNSConfig.Certificates.Enabled gates [nodecap.HTTPS] and
// cfg.AutoUpdate.Enabled controls the value of
// [tailcfg.NodeAttrDefaultAutoUpdate]. Admin and SSH are unconditional
// baseline.
func TestTailNodeBaselineGates(t *testing.T) {
@@ -306,6 +307,20 @@ func TestTailNodeBaselineGates(t *testing.T) {
nodecap.DefaultAutoUpdate: autoUpdate(true),
},
},
{
name: "certificates_on",
cfg: &types.Config{
DNSConfig: types.DNSConfig{
Certificates: types.CertificatesConfig{Enabled: true},
},
},
want: tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
nodecap.HTTPS: []tailcfg.RawMessage{},
nodecap.DefaultAutoUpdate: autoUpdate(false),
},
},
{
name: "taildrop_off_autoupdate_on",
cfg: &types.Config{
+21
View File
@@ -1902,6 +1902,27 @@ func (c *Config) CloneTailcfgDNSConfig() *tailcfg.DNSConfig {
return c.TailcfgDNSConfig.Clone()
}
// extraRecordAtOrBelow reports whether an extra DNS record is name or a
// subdomain of it. Case is ignored, as in TLS name matching; name is
// lowercase.
func (c *Config) extraRecordAtOrBelow(name string) bool {
tailcfgDNSMu.RLock()
defer tailcfgDNSMu.RUnlock()
if c.TailcfgDNSConfig == nil {
return false
}
for _, r := range c.TailcfgDNSConfig.ExtraRecords {
rn := strings.ToLower(strings.TrimSuffix(r.Name, "."))
if rn == name || strings.HasSuffix(rn, "."+name) {
return true
}
}
return false
}
// TailcfgDebug returns the [tailcfg.Debug] for full map responses: nil when
// logtail is enabled, leaving client log uploads as the client set them,
// otherwise an instruction to stop uploading logs.
+42
View File
@@ -785,6 +785,42 @@ func (nv NodeView) MarshalZerologObject(e *zerolog.Event) {
nv.ж.MarshalZerologObject(e)
}
// CertDomain returns the name the node may get a TLS certificate for with
// `tailscale cert`: its MagicDNS name without the trailing dot, exactly as
// the client matches it against its own name.
func (nv NodeView) CertDomain(cfg *Config) (string, bool) {
if !nv.Valid() || !cfg.DNSConfig.Certificates.Enabled {
return "", false
}
fqdn, err := nv.GetFQDN(cfg.BaseDomain)
if err != nil {
return "", false
}
domain := strings.TrimSuffix(fqdn, ".")
// 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 {
return "", false
}
// A node names itself through its hostname, so it must not get a
// certificate for a name the operator gave to a service. A wildcard
// order shares its challenge record with the bare name, so names
// below the node's are covered too.
if cfg.extraRecordAtOrBelow(domain) {
return "", false
}
return domain, true
}
// acmeChallengePrefix is prepended to a certificate name to form the TXT
// record name of its ACME DNS-01 challenge.
const acmeChallengePrefix = "_acme-challenge."
// Owner returns the owner for display purposes.
// For tagged nodes, returns [TaggedDevices]. For user-owned nodes, returns the user.
// Returns an invalid [UserView] if the node is in an orphaned state (no tags, no user).
@@ -1241,6 +1277,8 @@ func (nv NodeView) TailNode(
// Baseline caps every node receives, regardless of policy. Mirrors
// what Tailscale SaaS emits for a default tailnet.
// cfg.Taildrop.Enabled gates CapabilityFileSharing.
// cfg.DNSConfig.Certificates.Enabled gates HTTPS, without which
// `tailscale serve` refuses to serve HTTPS.
capMap := tailcfg.NodeCapMap{
nodecap.Admin: []tailcfg.RawMessage{},
nodecap.SSH: []tailcfg.RawMessage{},
@@ -1250,6 +1288,10 @@ func (nv NodeView) TailNode(
capMap[nodecap.FileSharing] = []tailcfg.RawMessage{}
}
if _, ok := nv.CertDomain(cfg); ok {
capMap[nodecap.HTTPS] = []tailcfg.RawMessage{}
}
// default-auto-update is always emitted; the value is a JSON bool
// reflecting cfg.AutoUpdate.Enabled. Clients read this on first
// netmap and store the default locally; subsequent control-plane