diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index c5d43ed55..a69663d00 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -75,8 +75,8 @@ var ( type Anonymizer struct { ipAnonymizer map[netip.Addr]netip.Addr domainAnonymizer map[string]string - // domainOrder caches the keys of domainAnonymizer sorted longest-first - // for AnonymizeString; it is rebuilt when the map gains entries. + // domainOrder caches the keys of domainAnonymizer that AnonymizeString + // replaces, sorted longest-first. mapDomain drops it on every write. domainOrder []string labelAnonymizer map[string]string labelAnonymized map[string]struct{} @@ -294,7 +294,27 @@ func (a *Anonymizer) AnonymizeIPString(ip string) string { return a.AnonymizeIP(addr).String() } +// AnonymizeDomain replaces the base of domain with a stable anonymized one, +// keeping the labels in front of it (default level) or numbering them (strict +// level). It accepts free text that may not be a domain at all, so a name with +// no dot is anonymized only once AnonymizeDomainName has established it as a +// zone; otherwise it is returned unchanged. func (a *Anonymizer) AnonymizeDomain(domain string) string { + return a.anonymizeDomain(domain, false) +} + +// AnonymizeDomainName anonymizes a value the caller knows to be a DNS name, +// such as a configured zone, match domain, or record name. A single-label name, +// which a custom zone can legitimately be, is anonymized and remembered as a +// zone, so names under it share its anonymized base and later mentions of it +// are anonymized as well. +func (a *Anonymizer) AnonymizeDomainName(domain string) string { + return a.anonymizeDomain(domain, true) +} + +// anonymizeDomain implements both entry points. newZone reports whether the +// caller may establish a single-label name as a zone. +func (a *Anonymizer) anonymizeDomain(domain string, newZone bool) string { baseDomain := domain hasDot := strings.HasSuffix(domain, ".") if hasDot { @@ -318,31 +338,61 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string { return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot) } - parts := strings.Split(baseDomain, ".") - if len(parts) < 2 { + baseForLookup := a.baseForLookup(baseDomain, newZone) + if baseForLookup == "" { return domain } - baseForLookup := parts[len(parts)-2] + "." + parts[len(parts)-1] - - anonymized, ok := a.domainAnonymizer[baseForLookup] - if !ok { - anonymizedBase := "anon-" + generateRandomString(5) + anonTLD - a.domainAnonymizer[baseForLookup] = anonymizedBase - anonymized = anonymizedBase + anonymized, mapped := a.domainAnonymizer[baseForLookup] + if !mapped { + anonymized = "anon-" + generateRandomString(5) + anonTLD + a.mapDomain(baseForLookup, anonymized) } result := strings.Replace(baseDomain, baseForLookup, anonymized, 1) - if a.level >= LevelStrict && len(parts) > 2 { + if a.level >= LevelStrict && baseDomain != baseForLookup { prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup) result = a.anonymizeLabels(prefix, "host") + "." + anonymized // The full mapping feeds AnonymizeString so seeded FQDNs are caught // in log lines as a whole, labels included. - a.domainAnonymizer[baseDomain] = result + a.mapDomain(baseDomain, result) } return withTrailingDot(result, hasDot) } +// mapDomain records an anonymized form for a domain key and invalidates the +// replacement order AnonymizeString caches. +func (a *Anonymizer) mapDomain(key, anonymized string) { + a.domainAnonymizer[key] = anonymized + a.domainOrder = nil +} + +// baseForLookup returns the key under which baseDomain's anonymized base is +// stored, or empty to leave baseDomain alone. +// +// The key is normally the last two labels, so every name under a domain shares +// one anonymized base. A single-label zone is its own key: names under it then +// key on the zone rather than on their own last two labels, which keeps the +// zone relationship visible. A single-label name that is not yet a known zone +// needs newZone to become one, since free text and address forms also reach +// here and must not be rewritten on a guess. +func (a *Anonymizer) baseForLookup(baseDomain string, newZone bool) string { + parts := strings.Split(baseDomain, ".") + last := parts[len(parts)-1] + + if _, known := a.domainAnonymizer[last]; known { + return last + } + if len(parts) > 1 { + return parts[len(parts)-2] + "." + last + } + + if !newZone || last == "" || strings.EqualFold(last, "localhost") { + return "" + } + return last +} + // anonymizePeerName replaces the labels in front of a protected suffix with // numbered peer placeholders, keeping the suffix, and records the full // mapping for string replacement in logs. The numbering keeps a peer @@ -351,7 +401,7 @@ func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string { prefix := strings.TrimSuffix(baseDomain, "."+suffix) result := a.anonymizeLabels(prefix, "peer") + "." + suffix if result != baseDomain { - a.domainAnonymizer[baseDomain] = result + a.mapDomain(baseDomain, result) } return result } @@ -438,25 +488,32 @@ func (a *Anonymizer) AnonymizeString(str string) string { return restoreZones(str) } -// sortedDomains returns the domain mappings longest-first, so a full-FQDN -// mapping (strict level) is applied before the base-domain mapping it -// contains. The order is rebuilt only when domainAnonymizer has grown. +// sortedDomains returns the domain mappings eligible for plain substring +// replacement, longest-first, so a full-FQDN mapping (strict level) is applied +// before the base-domain mapping it contains. A single-label name is held out: +// it carries no dot to anchor it, so replacing it by substring would also +// rewrite any longer word that happens to contain it. Such a name still gets +// anonymized wherever it is handled as a domain rather than as free text. +// The order is rebuilt only after mapDomain has added an entry. func (a *Anonymizer) sortedDomains() []string { - if len(a.domainOrder) == len(a.domainAnonymizer) { + if a.domainOrder != nil { return a.domainOrder } - a.domainOrder = a.domainOrder[:0] + order := make([]string, 0, len(a.domainAnonymizer)) for domain := range a.domainAnonymizer { - a.domainOrder = append(a.domainOrder, domain) + if strings.Contains(domain, ".") { + order = append(order, domain) + } } - slices.SortFunc(a.domainOrder, func(x, y string) int { + slices.SortFunc(order, func(x, y string) int { if d := len(y) - len(x); d != 0 { return d } return strings.Compare(x, y) }) - return a.domainOrder + a.domainOrder = order + return order } // anonymizeMACsInString replaces MAC addresses matched by re, skipping diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go index 7c3c7bcf8..b4d75c919 100644 --- a/client/anonymize/anonymize_test.go +++ b/client/anonymize/anonymize_test.go @@ -313,6 +313,122 @@ func TestAnonymizeDomain_StrictLevel(t *testing.T) { }) } +// TestAnonymizeDomain_WildcardSharesBase covers both orders because the base +// mapping is created by whichever form is seen first. +func TestAnonymizeDomain_WildcardSharesBase(t *testing.T) { + for _, level := range []anonymize.Level{anonymize.LevelDefault, anonymize.LevelStrict} { + t.Run(level.String(), func(t *testing.T) { + t.Run("wildcard first", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + wildcard := anonymizer.AnonymizeDomain("*.example.org") + bare := anonymizer.AnonymizeDomain("example.org") + assert.Equal(t, "*."+bare, wildcard, "the wildcard form should be the bare anon domain behind a kept *.") + assert.NotContains(t, wildcard, "example", "the original base should not survive") + }) + + t.Run("bare first", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + bare := anonymizer.AnonymizeDomain("example.org") + wildcard := anonymizer.AnonymizeDomain("*.example.org") + assert.Equal(t, "*."+bare, wildcard, "the wildcard form should be the bare anon domain behind a kept *.") + }) + + t.Run("in a log line", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + bare := anonymizer.AnonymizeDomain("example.org") + line := `{"Domains": ["*.example.org", "example.org"]}` + result := anonymizer.AnonymizeString(line) + assert.Equal(t, `{"Domains": ["*.`+bare+`", "`+bare+`"]}`, result, + "both forms in a log line should share one anon base and keep the wildcard prefix") + }) + }) + } +} + +// TestAnonymizeDomain_SingleLabelZone covers a custom zone whose name is a +// single label, as a bundle's DNS config can carry. Such a name has no +// two-label base to key on, so it used to pass through in the clear. +func TestAnonymizeDomain_SingleLabelZone(t *testing.T) { + for _, level := range []anonymize.Level{anonymize.LevelDefault, anonymize.LevelStrict} { + t.Run(level.String(), func(t *testing.T) { + t.Run("zone name is anonymized", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + zone := anonymizer.AnonymizeDomainName("corp.") + assert.Regexp(t, `^anon-[a-zA-Z0-9]+\.domain\.$`, zone, "a single-label zone should be anonymized and keep its trailing dot") + }) + + t.Run("names under the zone share its base", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + // The bundle generator anonymizes the zone before its records. + zone := anonymizer.AnonymizeDomainName("corp") + record := anonymizer.AnonymizeDomainName("host.corp") + assert.True(t, strings.HasSuffix(record, "."+zone), "a record under the zone should keep the zone's anon base, got %q for zone %q", record, zone) + assert.NotContains(t, record, "corp", "the original zone name should not survive") + }) + + t.Run("consistent regardless of order", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + first := anonymizer.AnonymizeDomainName("corp") + second := anonymizer.AnonymizeDomainName("corp") + assert.Equal(t, first, second, "the same zone should map consistently") + }) + }) + } +} + +// TestAnonymizeString_KnownZoneInLogLine covers the bundle order: the DNS +// config establishes the zone, then log lines naming it are anonymized through +// the domain= key even though the name carries no dot of its own. +func TestAnonymizeString_KnownZoneInLogLine(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + zone := anonymizer.AnonymizeDomainName("corp.") + line := "adding handler pattern: domain=corp. original: domain=corp. priority=75" + + result := anonymizer.AnonymizeString(line) + assert.Equal(t, "adding handler pattern: domain="+zone+" original: domain="+zone+" priority=75", result, + "a log line naming a known zone should carry the zone's anon domain") + assert.NotContains(t, result, "corp", "the original zone name should not survive") +} + +// TestAnonymizeDomain_UnknownSingleLabelUntouched pins that free text reaching +// AnonymizeDomain is left alone. Values that are not domains at all (an address, +// an internal placeholder) arrive here, so a bare label is only anonymized once +// something that knows it is a DNS name has established it as a zone. +func TestAnonymizeDomain_UnknownSingleLabelUntouched(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + assert.Equal(t, "corp", anonymizer.AnonymizeDomain("corp"), "an unknown bare label should pass through") + assert.Equal(t, "localhost", anonymizer.AnonymizeDomainName("localhost"), "localhost identifies nothing and should pass through") + assert.Equal(t, "2001:db8:ffff::", anonymizer.AnonymizeDomain("2001:db8:ffff::"), "an address form should pass through") +} + +// TestAnonymizeString_SingleLabelNotSubstringReplaced pins that a single-label +// mapping is never applied as a bare substring: it has no dot to anchor it, so +// doing so would corrupt any longer word containing it. +func TestAnonymizeString_SingleLabelNotSubstringReplaced(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + anonymizer.AnonymizeDomainName("corp") + anonBase := anonymizer.AnonymizeDomainName("corpsite.example") + + result := anonymizer.AnonymizeString("reaching corpsite.example over corporation") + assert.Equal(t, "reaching "+anonBase+" over corporation", result, + "the single-label mapping should not rewrite longer words that contain it") +} + func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index b362ae293..7b6513be7 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -1551,7 +1551,7 @@ func anonymizeRoute(route *mgmProto.Route, anonymizer *anonymize.Anonymizer) { } for i, domain := range route.Domains { - route.Domains[i] = anonymizer.AnonymizeDomain(domain) + route.Domains[i] = anonymizer.AnonymizeDomainName(domain) } route.NetID = anonymizer.AnonymizeString(route.NetID) @@ -1583,20 +1583,21 @@ func anonymizeBundleGenerators(servers []*mgmProto.NameServer, anonymizer *anony func anonymizeDomains(domains []string, anonymizer *anonymize.Anonymizer) { for i, domain := range domains { - domains[i] = anonymizer.AnonymizeDomain(domain) + domains[i] = anonymizer.AnonymizeDomainName(domain) } } func anonymizeCustomZones(zones []*mgmProto.CustomZone, anonymizer *anonymize.Anonymizer) { for _, zone := range zones { - zone.Domain = anonymizer.AnonymizeDomain(zone.Domain) + // The zone goes first: its records are then keyed on it. + zone.Domain = anonymizer.AnonymizeDomainName(zone.Domain) anonymizeRecords(zone.Records, anonymizer) } } func anonymizeRecords(records []*mgmProto.SimpleRecord, anonymizer *anonymize.Anonymizer) { for _, record := range records { - record.Name = anonymizer.AnonymizeDomain(record.Name) + record.Name = anonymizer.AnonymizeDomainName(record.Name) anonymizeRData(record, anonymizer) } } diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 17d520358..356273742 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -447,6 +447,52 @@ func TestAnonymizeNetworkMap(t *testing.T) { } } +// TestAnonymizeNetworkMap_SingleLabelZone covers the shape a real bundle +// carried: a custom zone named by a single label, alongside a nameserver group +// whose match domain is an ordinary two-label domain. The single-label zone +// used to pass through in the clear while its own records were anonymized. +func TestAnonymizeNetworkMap_SingleLabelZone(t *testing.T) { + networkMap := &mgmProto.NetworkMap{ + DNSConfig: &mgmProto.DNSConfig{ + NameServerGroups: []*mgmProto.NameServerGroup{ + { + NameServers: []*mgmProto.NameServer{{IP: "203.0.113.53"}}, + Domains: []string{"example.net"}, + }, + }, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "corp.", + Records: []*mgmProto.SimpleRecord{ + {Name: "test.corp.", Type: 1, RData: "203.0.113.10"}, + {Name: "app.corp.", Type: 1, RData: "203.0.113.10"}, + }, + }, + }, + }, + Routes: []*mgmProto.Route{ + {Network: "203.0.113.0/24", Domains: []string{"corp"}, NetID: "net-1"}, + }, + } + + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + require.NoError(t, anonymizeNetworkMap(networkMap, anonymizer), "anonymize the network map") + + zone := networkMap.DNSConfig.CustomZones[0] + assert.NotContains(t, zone.Domain, "corp", "the single-label zone name should not survive") + assert.Regexp(t, `^anon-[a-zA-Z0-9]+\.domain\.$`, zone.Domain, "the zone should be replaced by an anon domain, trailing dot kept") + + for _, record := range zone.Records { + assert.NotContains(t, record.Name, "corp", "a record name should not carry the original zone") + assert.True(t, strings.HasSuffix(record.Name, "."+zone.Domain), + "record %q should sit under the anonymized zone %q", record.Name, zone.Domain) + } + + assert.NotContains(t, networkMap.DNSConfig.NameServerGroups[0].Domains[0], "example", + "the nameserver match domain should not survive") + assert.NotContains(t, networkMap.Routes[0].Domains[0], "corp", "a route domain should not survive") +} + func TestIsSensitiveEnvVar(t *testing.T) { tests := []struct { key string diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 3af912792..758be74da 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -356,7 +356,7 @@ func (s *DefaultServer) RegisterHandler(domains domain.List, handler dns.Handler } func (s *DefaultServer) registerHandler(domains []string, handler dns.Handler, priority int) { - log.Debugf("registering handler %s with priority %d for %v", handler, priority, domains) + log.Debugf("registering handler %s with priority %d for %s", handler, priority, joinDomainsForLog(domains)) for _, domain := range domains { if domain == "" { @@ -417,7 +417,7 @@ func (s *DefaultServer) CancelBatch() { } func (s *DefaultServer) deregisterHandler(domains []string, priority int) { - log.Debugf("deregistering handler with priority %d for %v", priority, domains) + log.Debugf("deregistering handler with priority %d for %s", priority, joinDomainsForLog(domains)) for _, domain := range domains { if domain == "" { @@ -1362,6 +1362,16 @@ func joinAddrPorts(servers []netip.AddrPort) string { return strings.Join(parts, ", ") } +// joinDomainsForLog renders domains as domain= tokens, the form the +// debug bundle recognizes as a DNS name when it anonymizes a log line. +func joinDomainsForLog(domains []string) string { + parts := make([]string, 0, len(domains)) + for _, d := range domains { + parts = append(parts, "domain="+d) + } + return strings.Join(parts, " ") +} + // generateGroupKey returns a stable identity for an NS group so health // state (everHealthy / warningActive) survives reorderings in the // configured nameserver or domain lists. diff --git a/client/status/status.go b/client/status/status.go index 1c204cdb1..3d598c46c 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -1023,7 +1023,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { for i, nsGroup := range overview.NSServerGroups { for j, domain := range nsGroup.Domains { - overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain) + overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomainName(domain) } for j, ns := range nsGroup.Servers { host, port, err := net.SplitHostPort(ns)