diff --git a/hscontrol/api/v2/settings.go b/hscontrol/api/v2/settings.go index 262ec554c..f42d3c90e 100644 --- a/hscontrol/api/v2/settings.go +++ b/hscontrol/api/v2/settings.go @@ -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 }) diff --git a/hscontrol/apiv2_settings_test.go b/hscontrol/apiv2_settings_test.go index 62b88bd75..83b6e26e5 100644 --- a/hscontrol/apiv2_settings_test.go +++ b/hscontrol/apiv2_settings_test.go @@ -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)) diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index 361178033..4f39ec35b 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -147,6 +147,10 @@ func generateDNSConfig( } } + if domain, ok := node.CertDomain(cfg); ok { + dnsConfig.CertDomains = []string{domain} + } + return dnsConfig } diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index 86acf996f..2eb63e479 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -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") + }) + } +} diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index ddee26ee4..4daa6a93c 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -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{ diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 205b656f2..1df599b3b 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -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. diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 7c7fbd24b..b9bb4a4f6 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -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