mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-23 06:46:49 +09:00
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:
1 parent
a928125afa
commit
3f6e54cbd5
7 files changed
+190
-15
No files matched your search
@@ -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
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -147,6 +147,10 @@ func generateDNSConfig(
|
||||
}
|
||||
}
|
||||
|
||||
if domain, ok := node.CertDomain(cfg); ok {
|
||||
dnsConfig.CertDomains = []string{domain}
|
||||
}
|
||||
|
||||
return dnsConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user