From fe960bd6540209398a49d6ca160a59df2cbaddb5 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 7 Aug 2026 16:55:42 +0200 Subject: [PATCH] Address review comments --- client/anonymize/anonymize.go | 65 +++++++++++++++++---------- client/anonymize/anonymize_test.go | 4 ++ client/internal/debug/wgshow.go | 6 ++- client/status/status.go | 10 ----- client/ui/i18n/locales/en/common.json | 2 +- 5 files changed, 51 insertions(+), 36 deletions(-) diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index 09a860aec..f6eacd0f4 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -43,6 +43,7 @@ func ParseLevel(s string) Level { } } +// String returns the wire form of the level: "default" or "strict". func (l Level) String() string { if l >= LevelStrict { return "strict" @@ -67,17 +68,20 @@ var ( type Anonymizer struct { ipAnonymizer map[netip.Addr]netip.Addr domainAnonymizer map[string]string - labelAnonymizer map[string]string - labelAnonymized map[string]struct{} - labelCounter uint32 - macAnonymizer map[string]string - macCounter uint32 - wgKeyAnonymizer map[string]string - wgKeyAnonymized map[string]struct{} - currentAnonIPv4 netip.Addr - currentAnonIPv6 netip.Addr - startAnonIPv4 netip.Addr - startAnonIPv6 netip.Addr + // domainOrder caches the keys of domainAnonymizer sorted longest-first + // for AnonymizeString; it is rebuilt when the map gains entries. + domainOrder []string + labelAnonymizer map[string]string + labelAnonymized map[string]struct{} + labelCounter uint32 + macAnonymizer map[string]string + macCounter uint32 + wgKeyAnonymizer map[string]string + wgKeyAnonymized map[string]struct{} + currentAnonIPv4 netip.Addr + currentAnonIPv6 netip.Addr + startAnonIPv4 netip.Addr + startAnonIPv6 netip.Addr // LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT, // link-local), replacing them from the dedicated internal pools below so @@ -136,6 +140,10 @@ func (a *Anonymizer) SetLevel(level Level) { } func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { + // Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps + // like 192.168.1.1. + ip = ip.Unmap() + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || @@ -393,19 +401,7 @@ func (a *Anonymizer) AnonymizeString(str string) string { str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) - // Longest mappings first, so a full-FQDN mapping (strict level) is applied - // before the base-domain mapping it contains. - domains := make([]string, 0, len(a.domainAnonymizer)) - for domain := range a.domainAnonymizer { - domains = append(domains, domain) - } - slices.SortFunc(domains, func(x, y string) int { - if d := len(y) - len(x); d != 0 { - return d - } - return strings.Compare(x, y) - }) - for _, domain := range domains { + for _, domain := range a.sortedDomains() { str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain]) } @@ -425,6 +421,27 @@ func (a *Anonymizer) AnonymizeString(str string) string { return 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. +func (a *Anonymizer) sortedDomains() []string { + if len(a.domainOrder) == len(a.domainAnonymizer) { + return a.domainOrder + } + + a.domainOrder = a.domainOrder[:0] + for domain := range a.domainAnonymizer { + a.domainOrder = append(a.domainOrder, domain) + } + slices.SortFunc(a.domainOrder, func(x, y string) int { + if d := len(y) - len(x); d != 0 { + return d + } + return strings.Compare(x, y) + }) + return a.domainOrder +} + // anonymizeMACsInString replaces MAC addresses matched by re, skipping // matches that directly adjoin another sep so a six-group run inside a longer // separated sequence is left alone. diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go index 2ea95d3d3..7c3c7bcf8 100644 --- a/client/anonymize/anonymize_test.go +++ b/client/anonymize/anonymize_test.go @@ -85,6 +85,9 @@ func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) { // ULA is anonymized even at the default level: its random global ID // uniquely fingerprints the network, unlike shared RFC 1918 space. {"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"}, + // 4-in-6 addresses classify like their unmapped IPv4 form. + {"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"}, + {"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"}, } for _, tc := range tests { @@ -124,6 +127,7 @@ func TestAnonymizeIP_StrictLevel(t *testing.T) { {"Well known split marker", "128.0.0.0", "128.0.0.0"}, {"In internal pool range", "198.18.0.3", "198.18.0.3"}, {"In public pool range", "198.51.100.0", "198.51.100.0"}, + {"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"}, } for _, tc := range tests { diff --git a/client/internal/debug/wgshow.go b/client/internal/debug/wgshow.go index 6ac66db80..ee24902e6 100644 --- a/client/internal/debug/wgshow.go +++ b/client/internal/debug/wgshow.go @@ -54,7 +54,11 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string { if len(peer.AllowedIPs) > 0 { var ipStrings []string for _, ipnet := range peer.AllowedIPs { - ipStrings = append(ipStrings, ipnet.String()) + ipStr := ipnet.String() + if g.anonymize { + ipStr = g.anonymizer.AnonymizeIPString(ipStr) + } + ipStrings = append(ipStrings, ipStr) } sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", "))) } diff --git a/client/status/status.go b/client/status/status.go index 97ca411c2..1c204cdb1 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -1013,7 +1013,6 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error) overview.PubKey = a.AnonymizeWGKey(overview.PubKey) - overview.FQDN = a.AnonymizeDomain(overview.FQDN) overview.IP = a.AnonymizeIPString(overview.IP) overview.IPv6 = a.AnonymizeIPString(overview.IPv6) for i, detail := range overview.Relays.Details { @@ -1034,15 +1033,6 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { } } - for i, event := range overview.Events { - event.Message = a.AnonymizeString(event.Message) - event.UserMessage = a.AnonymizeString(event.UserMessage) - for k, v := range event.Metadata { - event.Metadata[k] = a.AnonymizeString(v) - } - overview.Events[i] = event - } - for i, route := range overview.Networks { overview.Networks[i] = a.AnonymizeRoute(route) } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 05c5cc2fa..373173cb4 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1012,7 +1012,7 @@ "description": "Helper text under the anonymization dropdown. The level details live in the info tooltip." }, "settings.troubleshooting.anonymize.info": { - "message": "Default keeps internal IP addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.", + "message": "Default keeps internal IPv4 addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.", "description": "Info tooltip explaining the anonymization levels. 'RFC 1918', 'CGNAT', 'link-local', and 'WireGuard' are technical terms — keep them." }, "settings.troubleshooting.anonymize.none": {