diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go new file mode 100644 index 00000000000..acbd0441e1c --- /dev/null +++ b/client/anonymize/anonymize.go @@ -0,0 +1,212 @@ +package anonymize + +import ( + "crypto/rand" + "fmt" + "math/big" + "net" + "net/netip" + "net/url" + "regexp" + "slices" + "strings" +) + +type Anonymizer struct { + ipAnonymizer map[netip.Addr]netip.Addr + domainAnonymizer map[string]string + currentAnonIPv4 netip.Addr + currentAnonIPv6 netip.Addr + startAnonIPv4 netip.Addr + startAnonIPv6 netip.Addr +} + +func DefaultAddresses() (netip.Addr, netip.Addr) { + // 192.51.100.0, 100:: + return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.AddrFrom16([16]byte{0x01}) +} + +func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer { + return &Anonymizer{ + ipAnonymizer: map[netip.Addr]netip.Addr{}, + domainAnonymizer: map[string]string{}, + currentAnonIPv4: startIPv4, + currentAnonIPv6: startIPv6, + startAnonIPv4: startIPv4, + startAnonIPv6: startIPv6, + } +} + +func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { + if ip.IsLoopback() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsInterfaceLocalMulticast() || + ip.IsPrivate() || + ip.IsUnspecified() || + ip.IsMulticast() || + isWellKnown(ip) || + a.isInAnonymizedRange(ip) { + + return ip + } + + if _, ok := a.ipAnonymizer[ip]; !ok { + if ip.Is4() { + a.ipAnonymizer[ip] = a.currentAnonIPv4 + a.currentAnonIPv4 = a.currentAnonIPv4.Next() + } else { + a.ipAnonymizer[ip] = a.currentAnonIPv6 + a.currentAnonIPv6 = a.currentAnonIPv6.Next() + } + } + return a.ipAnonymizer[ip] +} + +// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs +func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool { + if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 { + return true + } else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 { + return true + } + return false +} + +func (a *Anonymizer) AnonymizeIPString(ip string) string { + addr, err := netip.ParseAddr(ip) + if err != nil { + return ip + } + + return a.AnonymizeIP(addr).String() +} + +func (a *Anonymizer) AnonymizeDomain(domain string) string { + if strings.HasSuffix(domain, "netbird.io") || + strings.HasSuffix(domain, "netbird.selfhosted") || + strings.HasSuffix(domain, "netbird.cloud") || + strings.HasSuffix(domain, "netbird.stage") || + strings.HasSuffix(domain, ".domain") { + return domain + } + + parts := strings.Split(domain, ".") + if len(parts) < 2 { + return domain + } + + baseDomain := parts[len(parts)-2] + "." + parts[len(parts)-1] + + anonymized, ok := a.domainAnonymizer[baseDomain] + if !ok { + anonymizedBase := "anon-" + generateRandomString(5) + ".domain" + a.domainAnonymizer[baseDomain] = anonymizedBase + anonymized = anonymizedBase + } + + return strings.Replace(domain, baseDomain, anonymized, 1) +} + +func (a *Anonymizer) AnonymizeURI(uri string) string { + u, err := url.Parse(uri) + if err != nil { + return uri + } + + var anonymizedHost string + if u.Opaque != "" { + host, port, err := net.SplitHostPort(u.Opaque) + if err == nil { + anonymizedHost = fmt.Sprintf("%s:%s", a.AnonymizeDomain(host), port) + } else { + anonymizedHost = a.AnonymizeDomain(u.Opaque) + } + u.Opaque = anonymizedHost + } else if u.Host != "" { + host, port, err := net.SplitHostPort(u.Host) + if err == nil { + anonymizedHost = fmt.Sprintf("%s:%s", a.AnonymizeDomain(host), port) + } else { + anonymizedHost = a.AnonymizeDomain(u.Host) + } + u.Host = anonymizedHost + } + return u.String() +} + +func (a *Anonymizer) AnonymizeString(str string) string { + ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`) + ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`) + + str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) + str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) + + for domain, anonDomain := range a.domainAnonymizer { + str = strings.ReplaceAll(str, domain, anonDomain) + } + + str = a.AnonymizeSchemeURI(str) + str = a.AnonymizeDNSLogLine(str) + + return str +} + +// AnonymizeSchemeURI finds and anonymizes URIs with stun, stuns, turn, and turns schemes. +func (a *Anonymizer) AnonymizeSchemeURI(text string) string { + re := regexp.MustCompile(`(?i)\b(stuns?:|turns?:|https?://)\S+\b`) + + return re.ReplaceAllStringFunc(text, a.AnonymizeURI) +} + +// AnonymizeDNSLogLine anonymizes domain names in DNS log entries by replacing them with a random string. +func (a *Anonymizer) AnonymizeDNSLogLine(logEntry string) string { + domainPattern := `dns\.Question{Name:"([^"]+)",` + domainRegex := regexp.MustCompile(domainPattern) + + return domainRegex.ReplaceAllStringFunc(logEntry, func(match string) string { + parts := strings.Split(match, `"`) + if len(parts) >= 2 { + domain := parts[1] + if strings.HasSuffix(domain, ".domain") { + return match + } + randomDomain := generateRandomString(10) + ".domain" + return strings.Replace(match, domain, randomDomain, 1) + } + return match + }) +} + +func isWellKnown(addr netip.Addr) bool { + wellKnown := []string{ + "8.8.8.8", "8.8.4.4", // Google DNS IPv4 + "2001:4860:4860::8888", "2001:4860:4860::8844", // Google DNS IPv6 + "1.1.1.1", "1.0.0.1", // Cloudflare DNS IPv4 + "2606:4700:4700::1111", "2606:4700:4700::1001", // Cloudflare DNS IPv6 + "9.9.9.9", "149.112.112.112", // Quad9 DNS IPv4 + "2620:fe::fe", "2620:fe::9", // Quad9 DNS IPv6 + } + + if slices.Contains(wellKnown, addr.String()) { + return true + } + + cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0}) + cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10) + + return cgnatRange.Contains(addr) +} + +func generateRandomString(length int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, length) + for i := range result { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + continue + } + result[i] = letters[num.Int64()] + } + return string(result) +} diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go new file mode 100644 index 00000000000..e660749ec5d --- /dev/null +++ b/client/anonymize/anonymize_test.go @@ -0,0 +1,223 @@ +package anonymize_test + +import ( + "net/netip" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/anonymize" +) + +func TestAnonymizeIP(t *testing.T) { + startIPv4 := netip.MustParseAddr("198.51.100.0") + startIPv6 := netip.MustParseAddr("100::") + anonymizer := anonymize.NewAnonymizer(startIPv4, startIPv6) + + tests := []struct { + name string + ip string + expect string + }{ + {"Well known", "8.8.8.8", "8.8.8.8"}, + {"First Public IPv4", "1.2.3.4", "198.51.100.0"}, + {"Second Public IPv4", "4.3.2.1", "198.51.100.1"}, + {"Repeated IPv4", "1.2.3.4", "198.51.100.0"}, + {"Private IPv4", "192.168.1.1", "192.168.1.1"}, + {"First Public IPv6", "2607:f8b0:4005:805::200e", "100::"}, + {"Second Public IPv6", "a::b", "100::1"}, + {"Repeated IPv6", "2607:f8b0:4005:805::200e", "100::"}, + {"Private IPv6", "fe80::1", "fe80::1"}, + {"In Range IPv4", "198.51.100.2", "198.51.100.2"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ip := netip.MustParseAddr(tc.ip) + anonymizedIP := anonymizer.AnonymizeIP(ip) + if anonymizedIP.String() != tc.expect { + t.Errorf("%s: expected %s, got %s", tc.name, tc.expect, anonymizedIP) + } + }) + } +} + +func TestAnonymizeDNSLogLine(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) + testLog := `2024-04-23T20:01:11+02:00 TRAC client/internal/dns/local.go:25: received question: dns.Question{Name:"example.com", Qtype:0x1c, Qclass:0x1}` + + result := anonymizer.AnonymizeDNSLogLine(testLog) + require.NotEqual(t, testLog, result) + assert.NotContains(t, result, "example.com") +} + +func TestAnonymizeDomain(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) + tests := []struct { + name string + domain string + expectPattern string + shouldAnonymize bool + }{ + { + "General Domain", + "example.com", + `^anon-[a-zA-Z0-9]+\.domain$`, + true, + }, + { + "Subdomain", + "sub.example.com", + `^sub\.anon-[a-zA-Z0-9]+\.domain$`, + true, + }, + { + "Protected Domain", + "netbird.io", + `^netbird\.io$`, + false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeDomain(tc.domain) + if tc.shouldAnonymize { + assert.Regexp(t, tc.expectPattern, result, "The anonymized domain should match the expected pattern") + assert.NotContains(t, result, tc.domain, "The original domain should not be present in the result") + } else { + assert.Equal(t, tc.domain, result, "Protected domains should not be anonymized") + } + }) + } +} + +func TestAnonymizeURI(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) + tests := []struct { + name string + uri string + regex string + }{ + { + "HTTP URI with Port", + "http://example.com:80/path", + `^http://anon-[a-zA-Z0-9]+\.domain:80/path$`, + }, + { + "HTTP URI without Port", + "http://example.com/path", + `^http://anon-[a-zA-Z0-9]+\.domain/path$`, + }, + { + "Opaque URI with Port", + "stun:example.com:80?transport=udp", + `^stun:anon-[a-zA-Z0-9]+\.domain:80\?transport=udp$`, + }, + { + "Opaque URI without Port", + "stun:example.com?transport=udp", + `^stun:anon-[a-zA-Z0-9]+\.domain\?transport=udp$`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeURI(tc.uri) + assert.Regexp(t, regexp.MustCompile(tc.regex), result, "URI should match expected pattern") + require.NotContains(t, result, "example.com", "Original domain should not be present") + }) + } +} + +func TestAnonymizeSchemeURI(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) + tests := []struct { + name string + input string + expect string + }{ + {"STUN URI in text", "Connection made via stun:example.com", `Connection made via stun:anon-[a-zA-Z0-9]+\.domain`}, + {"TURN URI in log", "Failed attempt turn:some.example.com:3478?transport=tcp: retrying", `Failed attempt turn:some.anon-[a-zA-Z0-9]+\.domain:3478\?transport=tcp: retrying`}, + {"HTTPS URI in message", "Visit https://example.com for more", `Visit https://anon-[a-zA-Z0-9]+\.domain for more`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeSchemeURI(tc.input) + assert.Regexp(t, tc.expect, result, "The anonymized output should match expected pattern") + require.NotContains(t, result, "example.com", "Original domain should not be present") + }) + } +} + +func TestAnonymizString_MemorizedDomain(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) + domain := "example.com" + anonymizedDomain := anonymizer.AnonymizeDomain(domain) + + sampleString := "This is a test string including the domain example.com which should be anonymized." + + firstPassResult := anonymizer.AnonymizeString(sampleString) + secondPassResult := anonymizer.AnonymizeString(firstPassResult) + + assert.Contains(t, firstPassResult, anonymizedDomain, "The domain should be anonymized in the first pass") + assert.NotContains(t, firstPassResult, domain, "The original domain should not appear in the first pass output") + + assert.Equal(t, firstPassResult, secondPassResult, "The second pass should not further anonymize the string") +} + +func TestAnonymizeString_DoubleURI(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) + domain := "example.com" + anonymizedDomain := anonymizer.AnonymizeDomain(domain) + + sampleString := "Check out our site at https://example.com for more info." + + firstPassResult := anonymizer.AnonymizeString(sampleString) + secondPassResult := anonymizer.AnonymizeString(firstPassResult) + + assert.Contains(t, firstPassResult, "https://"+anonymizedDomain, "The URI should be anonymized in the first pass") + assert.NotContains(t, firstPassResult, "https://example.com", "The original URI should not appear in the first pass output") + + assert.Equal(t, firstPassResult, secondPassResult, "The second pass should not further anonymize the URI") +} + +func TestAnonymizeString_IPAddresses(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + tests := []struct { + name string + input string + expect string + }{ + { + name: "IPv4 Address", + input: "Error occurred at IP 122.138.1.1", + expect: "Error occurred at IP 198.51.100.0", + }, + { + name: "IPv6 Address", + input: "Access attempted from 2001:db8::ff00:42", + expect: "Access attempted from 100::", + }, + { + name: "IPv6 Address with Port", + input: "Access attempted from [2001:db8::ff00:42]:8080", + expect: "Access attempted from [100::]:8080", + }, + { + name: "Both IPv4 and IPv6", + input: "IPv4: 142.108.0.1 and IPv6: 2001:db8::ff00:43", + expect: "IPv4: 198.51.100.1 and IPv6: 100::1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeString(tc.input) + assert.Equal(t, tc.expect, result, "IP addresses should be anonymized correctly") + }) + } +} diff --git a/client/cmd/debug.go b/client/cmd/debug.go new file mode 100644 index 00000000000..4deff11a6ff --- /dev/null +++ b/client/cmd/debug.go @@ -0,0 +1,248 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "Debugging commands", + Long: "Provides commands for debugging and logging control within the Netbird daemon.", +} + +var debugBundleCmd = &cobra.Command{ + Use: "bundle", + Example: " netbird debug bundle", + Short: "Create a debug bundle", + Long: "Generates a compressed archive of the daemon's logs and status for debugging purposes.", + RunE: debugBundle, +} + +var logCmd = &cobra.Command{ + Use: "log", + Short: "Manage logging for the Netbird daemon", + Long: `Commands to manage logging settings for the Netbird daemon, including ICE, gRPC, and general log levels.`, +} + +var logLevelCmd = &cobra.Command{ + Use: "level ", + Short: "Set the logging level for this session", + Long: `Sets the logging level for the current session. This setting is temporary and will revert to the default on daemon restart. +Available log levels are: + panic: for panic level, highest level of severity + fatal: for fatal level errors that cause the program to exit + error: for error conditions + warn: for warning conditions + info: for informational messages + debug: for debug-level messages + trace: for trace-level messages, which include more fine-grained information than debug`, + Args: cobra.ExactArgs(1), + RunE: setLogLevel, +} + +var forCmd = &cobra.Command{ + Use: "for