package debug import ( "archive/zip" "bytes" "encoding/json" "fmt" "io" "net" "net/netip" "net/url" "os" "path/filepath" "reflect" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/configs" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/netiputil" ) func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte { t.Helper() b, err := netiputil.EncodePrefix(p) require.NoError(t, err) return b } func TestAnonymizeStateFile(t *testing.T) { testState := map[string]json.RawMessage{ "null_state": json.RawMessage("null"), "test_state": mustMarshal(map[string]any{ // Test simple fields "public_ip": "203.0.113.1", "private_ip": "192.168.1.1", "protected_ip": "100.64.0.1", "well_known_ip": "8.8.8.8", "ipv6_addr": "2001:db8::1", "private_ipv6": "fd00::1", "domain": "test.example.com", "uri": "stun:stun.example.com:3478", "uri_with_ip": "turn:203.0.113.1:3478", "netbird_domain": "device.netbird.cloud", // Test CIDR ranges "public_cidr": "203.0.113.0/24", "private_cidr": "192.168.0.0/16", "protected_cidr": "100.64.0.0/10", "ipv6_cidr": "2001:db8::/32", "private_ipv6_cidr": "fd00::/8", // Test nested structures "nested": map[string]any{ "ip": "203.0.113.2", "domain": "nested.example.com", "more_nest": map[string]any{ "ip": "203.0.113.3", "domain": "deep.example.com", }, }, // Test arrays "string_array": []any{ "203.0.113.4", "test1.example.com", "test2.example.com", }, "object_array": []any{ map[string]any{ "ip": "203.0.113.5", "domain": "array1.example.com", }, map[string]any{ "ip": "203.0.113.6", "domain": "array2.example.com", }, }, // Test multiple occurrences of same value "duplicate_ip": "203.0.113.1", // Same as public_ip "duplicate_domain": "test.example.com", // Same as domain // Test URIs with various schemes "stun_uri": "stun:stun.example.com:3478", "turns_uri": "turns:turns.example.com:5349", "http_uri": "http://web.example.com:80", "https_uri": "https://secure.example.com:443", // Test strings that might look like IPs but aren't "not_ip": "300.300.300.300", "partial_ip": "192.168", "ip_like_string": "1234.5678", // Test mixed content strings "mixed_content": "Server at 203.0.113.1 (test.example.com) on port 80", // Test empty and special values "empty_string": "", "null_value": nil, "numeric_value": 42, "boolean_value": true, }), "route_state": mustMarshal(map[string]any{ "routes": []any{ map[string]any{ "network": "203.0.113.0/24", "gateway": "203.0.113.1", "domains": []any{ "route1.example.com", "route2.example.com", }, }, map[string]any{ "network": "2001:db8::/32", "gateway": "2001:db8::1", "domains": []any{ "route3.example.com", "route4.example.com", }, }, }, // Test map with IP/CIDR keys "refCountMap": map[string]any{ "203.0.113.1/32": map[string]any{ "Count": 1, "Out": map[string]any{ "IP": "192.168.0.1", "Intf": map[string]any{ "Name": "eth0", "Index": 1, }, }, }, "2001:db8::1/128": map[string]any{ "Count": 1, "Out": map[string]any{ "IP": "fe80::1", "Intf": map[string]any{ "Name": "eth0", "Index": 1, }, }, }, "10.0.0.1/32": map[string]any{ // private IP should remain unchanged "Count": 1, "Out": map[string]any{ "IP": "192.168.0.1", }, }, }, }), } anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) // Pre-seed the domains we need to verify in the test assertions anonymizer.AnonymizeDomain("test.example.com") anonymizer.AnonymizeDomain("nested.example.com") anonymizer.AnonymizeDomain("deep.example.com") anonymizer.AnonymizeDomain("array1.example.com") err := anonymizeStateFile(&testState, anonymizer) require.NoError(t, err) // Helper function to unmarshal and get nested values var state map[string]any err = json.Unmarshal(testState["test_state"], &state) require.NoError(t, err) // Test null state remains unchanged require.Equal(t, "null", string(testState["null_state"])) // Basic assertions assert.NotEqual(t, "203.0.113.1", state["public_ip"]) assert.Equal(t, "192.168.1.1", state["private_ip"]) // Private IP unchanged assert.Equal(t, "100.64.0.1", state["protected_ip"]) // Protected IP unchanged assert.Equal(t, "8.8.8.8", state["well_known_ip"]) // Well-known IP unchanged assert.NotEqual(t, "2001:db8::1", state["ipv6_addr"]) assert.NotEqual(t, "fd00::1", state["private_ipv6"]) // ULA IPv6 anonymized (global ID is a fingerprint) assert.NotEqual(t, "test.example.com", state["domain"]) assert.True(t, strings.HasSuffix(state["domain"].(string), ".domain")) assert.Equal(t, "device.netbird.cloud", state["netbird_domain"]) // Netbird domain unchanged // CIDR ranges assert.NotEqual(t, "203.0.113.0/24", state["public_cidr"]) assert.Contains(t, state["public_cidr"], "/24") // Prefix preserved assert.Equal(t, "192.168.0.0/16", state["private_cidr"]) // Private CIDR unchanged assert.Equal(t, "100.64.0.0/10", state["protected_cidr"]) // Protected CIDR unchanged assert.NotEqual(t, "2001:db8::/32", state["ipv6_cidr"]) assert.Contains(t, state["ipv6_cidr"], "/32") // IPv6 prefix preserved // Nested structures nested := state["nested"].(map[string]any) assert.NotEqual(t, "203.0.113.2", nested["ip"]) assert.NotEqual(t, "nested.example.com", nested["domain"]) moreNest := nested["more_nest"].(map[string]any) assert.NotEqual(t, "203.0.113.3", moreNest["ip"]) assert.NotEqual(t, "deep.example.com", moreNest["domain"]) // Arrays strArray := state["string_array"].([]any) assert.NotEqual(t, "203.0.113.4", strArray[0]) assert.NotEqual(t, "test1.example.com", strArray[1]) assert.True(t, strings.HasSuffix(strArray[1].(string), ".domain")) objArray := state["object_array"].([]any) firstObj := objArray[0].(map[string]any) assert.NotEqual(t, "203.0.113.5", firstObj["ip"]) assert.NotEqual(t, "array1.example.com", firstObj["domain"]) // Duplicate values should be anonymized consistently assert.Equal(t, state["public_ip"], state["duplicate_ip"]) assert.Equal(t, state["domain"], state["duplicate_domain"]) // URIs assert.NotContains(t, state["stun_uri"], "stun.example.com") assert.NotContains(t, state["turns_uri"], "turns.example.com") assert.NotContains(t, state["http_uri"], "web.example.com") assert.NotContains(t, state["https_uri"], "secure.example.com") // Non-IP strings should remain unchanged assert.Equal(t, "300.300.300.300", state["not_ip"]) assert.Equal(t, "192.168", state["partial_ip"]) assert.Equal(t, "1234.5678", state["ip_like_string"]) // Mixed content should have IPs and domains replaced mixedContent := state["mixed_content"].(string) assert.NotContains(t, mixedContent, "203.0.113.1") assert.NotContains(t, mixedContent, "test.example.com") assert.Contains(t, mixedContent, "Server at ") assert.Contains(t, mixedContent, " on port 80") // Special values should remain unchanged assert.Equal(t, "", state["empty_string"]) assert.Nil(t, state["null_value"]) assert.Equal(t, float64(42), state["numeric_value"]) assert.Equal(t, true, state["boolean_value"]) // Check route state var routeState map[string]any err = json.Unmarshal(testState["route_state"], &routeState) require.NoError(t, err) routes := routeState["routes"].([]any) route1 := routes[0].(map[string]any) assert.NotEqual(t, "203.0.113.0/24", route1["network"]) assert.Contains(t, route1["network"], "/24") assert.NotEqual(t, "203.0.113.1", route1["gateway"]) domains := route1["domains"].([]any) assert.True(t, strings.HasSuffix(domains[0].(string), ".domain")) assert.True(t, strings.HasSuffix(domains[1].(string), ".domain")) // Check map keys are anonymized refCountMap := routeState["refCountMap"].(map[string]any) hasPublicIPKey := false hasIPv6Key := false hasPrivateIPKey := false for key := range refCountMap { if strings.Contains(key, "203.0.113.1") { hasPublicIPKey = true } if strings.Contains(key, "2001:db8::1") { hasIPv6Key = true } if key == "10.0.0.1/32" { hasPrivateIPKey = true } } assert.False(t, hasPublicIPKey, "public IP in key should be anonymized") assert.False(t, hasIPv6Key, "IPv6 in key should be anonymized") assert.True(t, hasPrivateIPKey, "private IP in key should remain unchanged") } func mustMarshal(v any) json.RawMessage { data, err := json.Marshal(v) if err != nil { panic(err) } return data } func TestAnonymizeNetworkMap(t *testing.T) { origV6Prefix := netip.MustParsePrefix("2001:db8:abcd::5/64") networkMap := &mgmProto.NetworkMap{ PeerConfig: &mgmProto.PeerConfig{ Address: "203.0.113.5", AddressV6: mustEncodePrefix(t, origV6Prefix), Dns: "1.2.3.4", Fqdn: "peer1.corp.example.com", SshConfig: &mgmProto.SSHConfig{ SshPubKey: []byte("ssh-rsa AAAAB3NzaC1..."), }, }, RemotePeers: []*mgmProto.RemotePeerConfig{ { AllowedIps: []string{ "203.0.113.1/32", "2001:db8:1234::1/128", "192.168.1.1/32", "100.64.0.1/32", "10.0.0.1/32", }, Fqdn: "peer2.corp.example.com", SshConfig: &mgmProto.SSHConfig{ SshPubKey: []byte("ssh-rsa AAAAB3NzaC2..."), }, }, }, Routes: []*mgmProto.Route{ { Network: "197.51.100.0/24", Domains: []string{"prod.example.com", "staging.example.com"}, NetID: "net-123abc", }, }, DNSConfig: &mgmProto.DNSConfig{ NameServerGroups: []*mgmProto.NameServerGroup{ { NameServers: []*mgmProto.NameServer{ {IP: "8.8.8.8"}, {IP: "1.1.1.1"}, {IP: "203.0.113.53"}, }, Domains: []string{"example.com", "internal.example.com"}, }, }, CustomZones: []*mgmProto.CustomZone{ { Domain: "custom.example.com", Records: []*mgmProto.SimpleRecord{ { Name: "www.custom.example.com", Type: 1, RData: "203.0.113.10", }, { Name: "internal.custom.example.com", Type: 1, RData: "192.168.1.10", }, }, }, }, }, } // Create anonymizer with test addresses anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) // Anonymize the network map err := anonymizeNetworkMap(networkMap, anonymizer) require.NoError(t, err) // Test PeerConfig anonymization peerCfg := networkMap.PeerConfig require.NotEqual(t, "203.0.113.5", peerCfg.Address) // Verify DNS and FQDN are properly anonymized require.NotEqual(t, "1.2.3.4", peerCfg.Dns) require.NotEqual(t, "peer1.corp.example.com", peerCfg.Fqdn) require.True(t, strings.HasSuffix(peerCfg.Fqdn, ".domain")) // Verify AddressV6 is anonymized but preserves prefix length anonV6Prefix, err := netiputil.DecodePrefix(peerCfg.AddressV6) require.NoError(t, err) assert.Equal(t, origV6Prefix.Bits(), anonV6Prefix.Bits(), "prefix length must be preserved") assert.NotEqual(t, origV6Prefix.Addr(), anonV6Prefix.Addr(), "IPv6 address must be anonymized") // Verify SSH key is replaced require.Equal(t, []byte("ssh-placeholder-key"), peerCfg.SshConfig.SshPubKey) // Test RemotePeers anonymization remotePeer := networkMap.RemotePeers[0] // Verify FQDN is anonymized require.NotEqual(t, "peer2.corp.example.com", remotePeer.Fqdn) require.True(t, strings.HasSuffix(remotePeer.Fqdn, ".domain")) // Check that public IPs are anonymized but private IPs are preserved for _, allowedIP := range remotePeer.AllowedIps { ip, _, err := net.ParseCIDR(allowedIP) require.NoError(t, err) if ip.IsPrivate() || isInCGNATRange(ip) { require.Contains(t, []string{ "192.168.1.1/32", "100.64.0.1/32", "10.0.0.1/32", }, allowedIP) } else { require.NotContains(t, []string{ "203.0.113.1/32", "2001:db8:1234::1/128", }, allowedIP) } } // Test Routes anonymization route := networkMap.Routes[0] require.NotEqual(t, "197.51.100.0/24", route.Network) for _, domain := range route.Domains { require.True(t, strings.HasSuffix(domain, ".domain")) require.NotContains(t, domain, "example.com") } // Test DNS config anonymization dnsConfig := networkMap.DNSConfig nameServerGroup := dnsConfig.NameServerGroups[0] // Verify well-known DNS servers are preserved require.Equal(t, "8.8.8.8", nameServerGroup.NameServers[0].IP) require.Equal(t, "1.1.1.1", nameServerGroup.NameServers[1].IP) // Verify public DNS server is anonymized require.NotEqual(t, "203.0.113.53", nameServerGroup.NameServers[2].IP) // Verify domains are anonymized for _, domain := range nameServerGroup.Domains { require.True(t, strings.HasSuffix(domain, ".domain")) require.NotContains(t, domain, "example.com") } // Test CustomZones anonymization customZone := dnsConfig.CustomZones[0] require.True(t, strings.HasSuffix(customZone.Domain, ".domain")) require.NotContains(t, customZone.Domain, "example.com") // Verify records are properly anonymized for _, record := range customZone.Records { require.True(t, strings.HasSuffix(record.Name, ".domain")) require.NotContains(t, record.Name, "example.com") ip := net.ParseIP(record.RData) if ip != nil { if !ip.IsPrivate() { require.NotEqual(t, "203.0.113.10", record.RData) } else { require.Equal(t, "192.168.1.10", record.RData) } } } } func TestIsSensitiveEnvVar(t *testing.T) { tests := []struct { key string sensitive bool }{ {"NB_SETUP_KEY", true}, {"NB_API_TOKEN", true}, {"NB_CLIENT_SECRET", true}, {"NB_PASSWORD", true}, {"NB_CREDENTIAL", true}, // The token PIN, and the token URI, which may carry the PIN as pin-value. {"NB_TPM_PIN", true}, {"NB_CERT_PKCS11_URI", true}, {"NB_LOG_LEVEL", false}, {"NB_MANAGEMENT_URL", false}, {"NB_HOSTNAME", false}, {"HOME", false}, {"PATH", false}, } for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { assert.Equal(t, tt.sensitive, isSensitiveEnvVar(tt.key)) }) } } func TestSanitizeServiceEnvVars(t *testing.T) { tests := []struct { name string anonymize bool input map[string]any check func(t *testing.T, params map[string]any) }{ { name: "no env vars key", anonymize: false, input: map[string]any{"management_url": "https://mgmt.example.com"}, check: func(t *testing.T, params map[string]any) { t.Helper() assert.Equal(t, "https://mgmt.example.com", params["management_url"], "non-env fields should be untouched") _, ok := params[jsonKeyServiceEnv] assert.False(t, ok, "service_env_vars should not be added") }, }, { name: "non-NB vars are masked", anonymize: false, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ "HOME": "/root", "PATH": "/usr/bin", "NB_LOG_LEVEL": "debug", }, }, check: func(t *testing.T, params map[string]any) { t.Helper() env := params[jsonKeyServiceEnv].(map[string]any) assert.Equal(t, maskedValue, env["HOME"], "non-NB_ var should be masked") assert.Equal(t, maskedValue, env["PATH"], "non-NB_ var should be masked") assert.Equal(t, "debug", env["NB_LOG_LEVEL"], "safe NB_ var should pass through") }, }, { name: "sensitive NB vars are masked", anonymize: false, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ "NB_SETUP_KEY": "abc123", "NB_API_TOKEN": "tok_xyz", "NB_LOG_LEVEL": "info", }, }, check: func(t *testing.T, params map[string]any) { t.Helper() env := params[jsonKeyServiceEnv].(map[string]any) assert.Equal(t, maskedValue, env["NB_SETUP_KEY"], "sensitive NB_ var should be masked") assert.Equal(t, maskedValue, env["NB_API_TOKEN"], "sensitive NB_ var should be masked") assert.Equal(t, "info", env["NB_LOG_LEVEL"], "safe NB_ var should pass through") }, }, { name: "safe NB vars anonymized when anonymize is true", anonymize: true, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ "NB_MANAGEMENT_URL": "https://mgmt.example.com:443", "NB_LOG_LEVEL": "debug", "NB_SETUP_KEY": "secret", "SOME_OTHER": "val", }, }, check: func(t *testing.T, params map[string]any) { t.Helper() env := params[jsonKeyServiceEnv].(map[string]any) // Safe NB_ values should be anonymized (not the original, not masked) mgmtVal := env["NB_MANAGEMENT_URL"].(string) assert.NotEqual(t, "https://mgmt.example.com:443", mgmtVal, "should be anonymized") assert.NotEqual(t, maskedValue, mgmtVal, "should not be masked") logVal := env["NB_LOG_LEVEL"].(string) assert.NotEqual(t, maskedValue, logVal, "safe NB_ var should not be masked") // Sensitive and non-NB_ still masked assert.Equal(t, maskedValue, env["NB_SETUP_KEY"]) assert.Equal(t, maskedValue, env["SOME_OTHER"]) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) g := &BundleGenerator{ anonymize: tt.anonymize, anonymizer: anonymizer, } g.sanitizeServiceEnvVars(tt.input) tt.check(t, tt.input) }) } } func TestAddServiceParams(t *testing.T) { t.Run("missing service.json returns nil", func(t *testing.T) { g := &BundleGenerator{ anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), } origStateDir := configs.StateDir configs.StateDir = t.TempDir() t.Cleanup(func() { configs.StateDir = origStateDir }) err := g.addServiceParams() assert.NoError(t, err) }) t.Run("management_url anonymized when anonymize is true", func(t *testing.T) { dir := t.TempDir() origStateDir := configs.StateDir configs.StateDir = dir t.Cleanup(func() { configs.StateDir = origStateDir }) input := map[string]any{ jsonKeyManagementURL: "https://api.example.com:443", jsonKeyServiceEnv: map[string]any{ "NB_LOG_LEVEL": "trace", }, } data, err := json.Marshal(input) require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(dir, serviceParamsFile), data, 0600)) var buf bytes.Buffer zw := zip.NewWriter(&buf) g := &BundleGenerator{ anonymize: true, anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), archive: zw, } require.NoError(t, g.addServiceParams()) require.NoError(t, zw.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) require.NoError(t, err) require.Len(t, zr.File, 1) assert.Equal(t, serviceParamsBundle, zr.File[0].Name) rc, err := zr.File[0].Open() require.NoError(t, err) defer rc.Close() var result map[string]any require.NoError(t, json.NewDecoder(rc).Decode(&result)) mgmt := result[jsonKeyManagementURL].(string) assert.NotEqual(t, "https://api.example.com:443", mgmt, "management_url should be anonymized") assert.NotEmpty(t, mgmt) env := result[jsonKeyServiceEnv].(map[string]any) assert.NotEqual(t, maskedValue, env["NB_LOG_LEVEL"], "safe NB_ var should not be masked") }) t.Run("management_url preserved when anonymize is false", func(t *testing.T) { dir := t.TempDir() origStateDir := configs.StateDir configs.StateDir = dir t.Cleanup(func() { configs.StateDir = origStateDir }) input := map[string]any{ jsonKeyManagementURL: "https://api.example.com:443", } data, err := json.Marshal(input) require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(dir, serviceParamsFile), data, 0600)) var buf bytes.Buffer zw := zip.NewWriter(&buf) g := &BundleGenerator{ anonymize: false, anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), archive: zw, } require.NoError(t, g.addServiceParams()) require.NoError(t, zw.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) require.NoError(t, err) rc, err := zr.File[0].Open() require.NoError(t, err) defer rc.Close() var result map[string]any require.NoError(t, json.NewDecoder(rc).Decode(&result)) assert.Equal(t, "https://api.example.com:443", result[jsonKeyManagementURL], "management_url should be preserved") }) } // Helper function to check if IP is in CGNAT range func isInCGNATRange(ip net.IP) bool { cgnat := net.IPNet{ IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32), } return cgnat.Contains(ip) } func TestAnonymizeFirewallRules(t *testing.T) { // Example iptables-save output iptablesSave := `# Generated by iptables-save v1.8.7 on Thu Dec 19 10:00:00 2024 *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [0:0] -A INPUT -s 192.168.1.0/24 -j ACCEPT -A INPUT -s 44.192.140.1/32 -j DROP -A FORWARD -s 10.0.0.0/8 -j DROP -A FORWARD -s 44.192.140.0/24 -d 52.84.12.34/24 -j ACCEPT COMMIT *nat :PREROUTING ACCEPT [0:0] :INPUT ACCEPT [0:0] :OUTPUT ACCEPT [0:0] :POSTROUTING ACCEPT [0:0] -A POSTROUTING -s 192.168.100.0/24 -j MASQUERADE -A PREROUTING -d 44.192.140.10/32 -p tcp -m tcp --dport 80 -j DNAT --to-destination 192.168.1.10:80 COMMIT` // Example iptables -v -n -L output iptablesVerbose := `Chain INPUT (policy ACCEPT 0 packets, 0 bytes) pkts bytes target prot opt in out source destination 0 0 ACCEPT all -- * * 192.168.1.0/24 0.0.0.0/0 100 1024 DROP all -- * * 44.192.140.1 0.0.0.0/0 Chain FORWARD (policy ACCEPT 0 packets, 0 bytes) pkts bytes target prot opt in out source destination 0 0 DROP all -- * * 10.0.0.0/8 0.0.0.0/0 25 256 ACCEPT all -- * * 44.192.140.0/24 52.84.12.34/24 Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes) pkts bytes target prot opt in out source destination` // Example ip6tables-save output ip6tablesSave := `# Generated by ip6tables-save v1.8.7 on Thu Dec 19 10:00:00 2024 *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [0:0] -A INPUT -s fd00:1234::1/128 -j ACCEPT -A INPUT -s 2607:f8b0:4005::1/128 -j DROP -A FORWARD -s 2001:db8::/32 -d 2607:f8b0:4005::200e/128 -j ACCEPT COMMIT` // Example nftables output with IPv6 nftablesRules := `table inet filter { chain input { type filter hook input priority filter; policy accept; ip saddr 192.168.1.1 accept ip saddr 44.192.140.1 drop ip6 saddr 2607:f8b0:4005::1 drop ip6 saddr fd00:1234::1 accept } chain forward { type filter hook forward priority filter; policy accept; ip saddr 10.0.0.0/8 drop ip saddr 44.192.140.0/24 ip daddr 52.84.12.34/24 accept ip6 saddr 2001:db8::/32 ip6 daddr 2607:f8b0:4005::200e/128 accept } }` anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) // Test iptables-save anonymization anonIptablesSave := anonymizer.AnonymizeString(iptablesSave) // Private IP addresses should remain unchanged assert.Contains(t, anonIptablesSave, "192.168.1.0/24") assert.Contains(t, anonIptablesSave, "10.0.0.0/8") assert.Contains(t, anonIptablesSave, "192.168.100.0/24") assert.Contains(t, anonIptablesSave, "192.168.1.10") // Public IP addresses should be anonymized to the default range assert.NotContains(t, anonIptablesSave, "44.192.140.1") assert.NotContains(t, anonIptablesSave, "44.192.140.0/24") assert.NotContains(t, anonIptablesSave, "52.84.12.34") assert.Contains(t, anonIptablesSave, "198.51.100.") // Default anonymous range // Structure should be preserved assert.Contains(t, anonIptablesSave, "*filter") assert.Contains(t, anonIptablesSave, ":INPUT ACCEPT [0:0]") assert.Contains(t, anonIptablesSave, "COMMIT") assert.Contains(t, anonIptablesSave, "-j MASQUERADE") assert.Contains(t, anonIptablesSave, "--dport 80") // Test iptables verbose output anonymization anonIptablesVerbose := anonymizer.AnonymizeString(iptablesVerbose) // Private IP addresses should remain unchanged assert.Contains(t, anonIptablesVerbose, "192.168.1.0/24") assert.Contains(t, anonIptablesVerbose, "10.0.0.0/8") // Public IP addresses should be anonymized to the default range assert.NotContains(t, anonIptablesVerbose, "44.192.140.1") assert.NotContains(t, anonIptablesVerbose, "44.192.140.0/24") assert.NotContains(t, anonIptablesVerbose, "52.84.12.34") assert.Contains(t, anonIptablesVerbose, "198.51.100.") // Default anonymous range // Structure and counters should be preserved assert.Contains(t, anonIptablesVerbose, "Chain INPUT (policy ACCEPT 0 packets, 0 bytes)") assert.Contains(t, anonIptablesVerbose, "100 1024 DROP") assert.Contains(t, anonIptablesVerbose, "pkts bytes target") // Test nftables anonymization anonNftables := anonymizer.AnonymizeString(nftablesRules) // Private IP addresses should remain unchanged assert.Contains(t, anonNftables, "192.168.1.1") assert.Contains(t, anonNftables, "10.0.0.0/8") // Public IP addresses should be anonymized to the default range assert.NotContains(t, anonNftables, "44.192.140.1") assert.NotContains(t, anonNftables, "44.192.140.0/24") assert.NotContains(t, anonNftables, "52.84.12.34") assert.Contains(t, anonNftables, "198.51.100.") // Default anonymous range // Structure should be preserved assert.Contains(t, anonNftables, "table inet filter {") assert.Contains(t, anonNftables, "chain input {") assert.Contains(t, anonNftables, "type filter hook input priority filter; policy accept;") // IPv6 public addresses in nftables should be anonymized assert.NotContains(t, anonNftables, "2607:f8b0:4005::1") assert.NotContains(t, anonNftables, "2607:f8b0:4005::200e") assert.NotContains(t, anonNftables, "2001:db8::") assert.Contains(t, anonNftables, "2001:db8:ffff::") // Default anonymous v6 range // ULA addresses in nftables should be anonymized (global ID is a fingerprint) assert.NotContains(t, anonNftables, "fd00:1234::1") // IPv6 nftables structure preserved assert.Contains(t, anonNftables, "ip6 saddr") assert.Contains(t, anonNftables, "ip6 daddr") // Test ip6tables-save anonymization anonIp6tablesSave := anonymizer.AnonymizeString(ip6tablesSave) // ULA IPv6 should be anonymized (global ID is a fingerprint) assert.NotContains(t, anonIp6tablesSave, "fd00:1234::1/128") // Public IPv6 addresses should be anonymized assert.NotContains(t, anonIp6tablesSave, "2607:f8b0:4005::1") assert.NotContains(t, anonIp6tablesSave, "2607:f8b0:4005::200e") assert.NotContains(t, anonIp6tablesSave, "2001:db8::") assert.Contains(t, anonIp6tablesSave, "2001:db8:ffff::") // Default anonymous v6 range // Structure should be preserved assert.Contains(t, anonIp6tablesSave, "*filter") assert.Contains(t, anonIp6tablesSave, "COMMIT") assert.Contains(t, anonIp6tablesSave, "-j DROP") assert.Contains(t, anonIp6tablesSave, "-j ACCEPT") } // TestAddConfig_AllFieldsCovered uses reflection to ensure every field in // profilemanager.Config is either rendered in the debug bundle or explicitly // excluded. When a new field is added to Config, this test fails until the // developer either dumps it in addConfig/addCommonConfigFields or adds it to // the excluded set with a justification. func TestAddConfig_AllFieldsCovered(t *testing.T) { excluded := map[string]string{ "PrivateKey": "sensitive: WireGuard private key", "PreSharedKey": "sensitive: WireGuard pre-shared key", "SSHKey": "sensitive: SSH private key", "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", "Name": "non-config: profile name is not needed for debug purposes", "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", "probing": "non-config: marks a throwaway copy built to be diffed against; never set on a config anyone runs with", "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle", "Owners": "non-config: owner information is not needed for debug purposes", "CertPKCS11URI": "deprecated and ignored; sensitive: the URI may carry the token PIN as pin-value", "CertStoreDir": "deprecated and ignored: the directory comes from NB_CERT_STORE_DIR", } mURL, _ := url.Parse("https://api.example.com:443") bTrue := true iVal := 42 cfg := &profilemanager.Config{ PrivateKey: "priv", PreSharedKey: "psk", ManagementURL: mURL, WgIface: "wt0", WgPort: 51820, NetworkMonitor: &bTrue, IFaceBlackList: []string{"eth0"}, DisableIPv6Discovery: true, RosenpassEnabled: true, RosenpassPermissive: true, ServerSSHAllowed: &bTrue, RemoteJobsAllowed: &bTrue, EnableSSHRoot: &bTrue, EnableSSHSFTP: &bTrue, EnableSSHLocalPortForwarding: &bTrue, EnableSSHRemotePortForwarding: &bTrue, DisableSSHAuth: &bTrue, SSHJWTCacheTTL: &iVal, DisableClientRoutes: true, DisableServerRoutes: true, DisableDNS: true, DisableFirewall: true, BlockLANAccess: true, BlockInbound: true, DisableNotifications: &bTrue, DNSLabels: domain.List{}, SSHKey: "sshkey", NATExternalIPs: []string{"1.2.3.4"}, CustomDNSAddress: "1.1.1.1:53", DisableAutoConnect: true, DNSRouteInterval: 5 * time.Second, ClientCertPath: "/tmp/cert", ClientCertKeyPath: "/tmp/key", LazyConnection: "on", DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret", CertPKCS11URI: "pkcs11:token=netbird?pin-value=pin-secret", MTU: 1280, DisableIPv6: true, SyncMessageVersion: func(v int) *int { return &v }(1), } for _, anonymize := range []bool{false, true} { t.Run("anonymize="+map[bool]string{true: "true", false: "false"}[anonymize], func(t *testing.T) { g := &BundleGenerator{ anonymizer: newAnonymizerForTest(), internalConfig: cfg, anonymize: anonymize, } var sb strings.Builder g.addCommonConfigFields(&sb) rendered := sb.String() + renderAddConfigSpecific(g) // DebugBundleUploadURL is an MDM-provided value that can carry // credentials or signed query tokens. It is deliberately excluded // above; assert it never reaches the rendered bundle — neither the // field name nor the token — in either anonymize mode. assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle") assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle") // A leftover CertPKCS11URI may carry the token PIN as pin-value. assert.NotContains(t, rendered, "CertPKCS11URI", "PKCS#11 URI field must not be serialized into the debug bundle") assert.NotContains(t, rendered, "pin-secret", "PKCS#11 PIN must not leak into the debug bundle") val := reflect.ValueOf(cfg).Elem() typ := val.Type() var missing []string for i := 0; i < typ.NumField(); i++ { name := typ.Field(i).Name if _, ok := excluded[name]; ok { continue } if !strings.Contains(rendered, name+":") { missing = append(missing, name) } } if len(missing) > 0 { t.Fatalf("Config field(s) not present in debug bundle output: %v\n"+ "Either render the field in addCommonConfigFields/addConfig, "+ "or add it to the excluded map with a justification.", missing) } }) } } // renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize // branches in addConfig (ManagementURL, NATExternalIPs, CustomDNSAddress). // addCommonConfigFields covers the rest. Keeping this in the test mirrors the // production shape without needing to write an actual zip. func renderAddConfigSpecific(g *BundleGenerator) string { var sb strings.Builder if g.anonymize { if g.internalConfig.ManagementURL != nil { sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n") } sb.WriteString("NATExternalIPs: x\n") if g.internalConfig.CustomDNSAddress != "" { sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n") } } else { if g.internalConfig.ManagementURL != nil { sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n") } sb.WriteString("NATExternalIPs: x\n") if g.internalConfig.CustomDNSAddress != "" { sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n") } } return sb.String() } func newAnonymizerForTest() *anonymize.Anonymizer { return anonymize.NewAnonymizer(anonymize.DefaultAddresses()) } // writeProfileJSON writes a profile config with the given display name and // owner principals, mirroring what the profile manager persists. func writeProfileJSON(t *testing.T, path, name string, owners []string) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) data, err := json.Marshal(map[string]any{"Name": name, "Owners": owners}) require.NoError(t, err) require.NoError(t, os.WriteFile(path, data, 0o600)) } // setupProfilesDir points the profile manager at a temporary state directory // laid out the way the daemon writes it, with the default profile at the top // level and further profiles in subdirectories. func setupProfilesDir(t *testing.T) string { t.Helper() dir := t.TempDir() origDir := profilemanager.DefaultConfigPathDir origDefault := profilemanager.DefaultConfigPath origActive := profilemanager.ActiveProfileStatePath t.Cleanup(func() { profilemanager.DefaultConfigPathDir = origDir profilemanager.DefaultConfigPath = origDefault profilemanager.ActiveProfileStatePath = origActive }) profilemanager.DefaultConfigPathDir = dir profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json") profilemanager.ActiveProfileStatePath = filepath.Join(dir, "active_profile.json") return dir } func bundleFiles(t *testing.T, add func(g *BundleGenerator) error) map[string]string { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) g := &BundleGenerator{ anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), archive: zw, } require.NoError(t, add(g)) require.NoError(t, zw.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) require.NoError(t, err) files := make(map[string]string, len(zr.File)) for _, f := range zr.File { rc, err := f.Open() require.NoError(t, err) content, err := io.ReadAll(rc) require.NoError(t, rc.Close()) require.NoError(t, err) files[f.Name] = string(content) } return files } func TestAddProfiles(t *testing.T) { t.Run("collects id, name and owners per profile", func(t *testing.T) { dir := setupProfilesDir(t) writeProfileJSON(t, profilemanager.DefaultConfigPath, "default", nil) writeProfileJSON(t, filepath.Join(dir, "alice", "aaaa1111.json"), "work", []string{"uid:1000"}) writeProfileJSON(t, filepath.Join(dir, "bob", "bbbb2222.json"), "home", []string{"uid:1001"}) // State files sit next to the profiles and must not be listed as one. require.NoError(t, os.WriteFile(filepath.Join(dir, "alice", "aaaa1111.state.json"), []byte(`{"email":"a@b.c"}`), 0o600)) active, err := json.Marshal(map[string]string{"name": "aaaa1111", "username": "alice"}) require.NoError(t, err) require.NoError(t, os.WriteFile(profilemanager.ActiveProfileStatePath, active, 0o600)) files := bundleFiles(t, (*BundleGenerator).addProfiles) profiles := files[profilesBundleFile] require.NotEmpty(t, profiles, "bundle should contain %s", profilesBundleFile) assert.Contains(t, profiles, "Profiles found: 3", "default plus both subdirectory profiles should be listed") assert.Contains(t, profiles, "[aaaa1111]") assert.Contains(t, profiles, "Name: work") assert.Contains(t, profiles, "Owners: uid:1000") assert.Contains(t, profiles, "[bbbb2222]") assert.Contains(t, profiles, "Owners: uid:1001") assert.NotContains(t, profiles, "aaaa1111.state.json", "state files are not profiles") // The default profile carries no owners yet. assert.Contains(t, profiles, "Owners: (none)") assert.Equal(t, string(active), files[activeProfileBundleFile], "active profile state should be dumped verbatim") }) t.Run("reports every owner, not only the honored one", func(t *testing.T) { dir := setupProfilesDir(t) // The client honors the first owner only, so extra entries are invisible // to it and worth surfacing in the bundle. writeProfileJSON(t, filepath.Join(dir, "alice", "aaaa1111.json"), "work", []string{"uid:1000", "uid:1001", "bogus"}) entries := collectProfileEntries(nil) require.Len(t, entries, 1) assert.Equal(t, []string{"uid:1000", "uid:1001", "bogus"}, entries[0].owners, "the whole owners list should be reported") }) t.Run("marks the active profile in any subdirectory", func(t *testing.T) { dir := setupProfilesDir(t) writeProfileJSON(t, filepath.Join(dir, "some-dir", "aaaa1111.json"), "work", []string{"uid:1000"}) writeProfileJSON(t, filepath.Join(dir, "other-dir", "bbbb2222.json"), "home", []string{"uid:1001"}) active, err := json.Marshal(map[string]string{"name": "aaaa1111", "username": "nonexistent"}) require.NoError(t, err) require.NoError(t, os.WriteFile(profilemanager.ActiveProfileStatePath, active, 0o600)) entries := collectProfileEntries(mustActiveState(t)) require.Len(t, entries, 2) for _, entry := range entries { assert.Equal(t, entry.id == "aaaa1111", entry.isActive, "active state should match on ID alone, got %+v", entry) } }) t.Run("missing active profile state still lists profiles", func(t *testing.T) { dir := setupProfilesDir(t) writeProfileJSON(t, filepath.Join(dir, "alice", "aaaa1111.json"), "work", []string{"uid:1000"}) files := bundleFiles(t, (*BundleGenerator).addProfiles) assert.Contains(t, files[profilesBundleFile], "[aaaa1111]") assert.Contains(t, files[profilesBundleFile], "Active profile: none recorded") assert.NotContains(t, files, activeProfileBundleFile, "no state file means nothing to dump") }) t.Run("keeps an empty active profile state file", func(t *testing.T) { dir := setupProfilesDir(t) writeProfileJSON(t, filepath.Join(dir, "alice", "aaaa1111.json"), "work", []string{"uid:1000"}) // A truncated write leaves the file in place with no content. The bundle // has to show that, not look like the file was never written. require.NoError(t, os.WriteFile(profilemanager.ActiveProfileStatePath, nil, 0o600)) files := bundleFiles(t, (*BundleGenerator).addProfiles) raw, ok := files[activeProfileBundleFile] assert.True(t, ok, "an empty state file should still be dumped") assert.Empty(t, raw) assert.Contains(t, files[profilesBundleFile], "Active profile: unknown") }) t.Run("records a parse error and keeps the other profiles", func(t *testing.T) { dir := setupProfilesDir(t) require.NoError(t, os.MkdirAll(filepath.Join(dir, "alice"), 0o700)) require.NoError(t, os.WriteFile(filepath.Join(dir, "alice", "aaaa1111.json"), []byte("{broken"), 0o600)) writeProfileJSON(t, filepath.Join(dir, "alice", "bbbb2222.json"), "work", []string{"uid:1000"}) entries := collectProfileEntries(nil) require.Len(t, entries, 2) assert.Error(t, entries[0].loadErr, "the unreadable profile should carry its error") assert.Equal(t, "aaaa1111", entries[0].name, "an unreadable profile falls back to its ID as the name") assert.NoError(t, entries[1].loadErr) assert.Equal(t, "work", entries[1].name) }) } func mustActiveState(t *testing.T) *profilemanager.ActiveProfileState { t.Helper() state, _, err := readActiveProfileState() require.NoError(t, err) require.NotNil(t, state) return state } func TestRemoveStaleBundles(t *testing.T) { dir := t.TempDir() stale := filepath.Join(dir, "netbird.debug.111.zip") fresh := filepath.Join(dir, "netbird.debug.222.zip") other := filepath.Join(dir, "netbird.debug.333.txt") owned := filepath.Join(dir, "netbird.debug.444.zip") abandoned := filepath.Join(dir, "netbird.debug.555.zip") for _, p := range []string{stale, fresh, other, owned, abandoned} { require.NoError(t, os.WriteFile(p, []byte("x"), 0o600)) } exported, err := ExportBundle(owned) require.NoError(t, err) exportedAbandoned, err := ExportBundle(abandoned) require.NoError(t, err) old := time.Now().Add(-2 * time.Hour) for _, p := range []string{stale, other, exported} { require.NoError(t, os.Chtimes(p, old, old)) } ancient := time.Now().Add(-exportedBundleMaxAge - time.Hour) require.NoError(t, os.Chtimes(exportedAbandoned, ancient, ancient)) RemoveStaleBundles(dir, time.Hour) assert.NoFileExists(t, stale, "bundle older than maxAge should be removed") assert.FileExists(t, fresh, "bundle younger than maxAge must survive, it may still be uploading") assert.FileExists(t, other, "files outside the bundle pattern must not be touched") assert.NoFileExists(t, owned) assert.FileExists(t, exported, "exported bundle is caller-owned and must survive maxAge") assert.NoFileExists(t, exportedAbandoned, "exported bundle older than exportedBundleMaxAge is abandoned") } func TestBundleIncludesNetworkMap(t *testing.T) { for _, anonymize := range []bool{false, true} { t.Run(fmt.Sprintf("anonymize=%t", anonymize), func(t *testing.T) { g := NewBundleGenerator(GeneratorDependencies{ SyncResponse: &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 1}}, }, BundleConfig{Anonymize: anonymize}) require.Contains(t, bundleEntries(t, g), "network_map.json") }) } } func TestBundleOmitsNetworkMapWithoutSyncResponse(t *testing.T) { g := NewBundleGenerator(GeneratorDependencies{}, BundleConfig{}) require.NotContains(t, bundleEntries(t, g), "network_map.json") }