mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-06 13:39:07 +02:00
* [client] Add a debug bundle file export to the Android bridge The Android app can only upload a debug bundle and hand the user a key. Users who want to inspect what leaves their device before sharing it have no way to get the zip itself. Add DebugBundleFile, which generates the bundle into the cache directory and returns its path instead of uploading; the app copies it wherever the user chose and removes it. DebugBundle keeps its behavior. Both entry points share the unexported debugBundle with an upload switch, so the body stays where it was and merges cleanly with the MDM overlay change on main. Because the file variant leaves the zip to the caller and the upload variant only removes it after the upload finishes, a process killed in between leaves a zip behind in the cache. Remove stale bundles before generating a new one: RemoveStaleBundles deletes zips matching the generator's pattern that are older than an hour. Remote debug jobs write to the same directory, so younger files are treated as still in use. * Preserve network map for debug bundle on Android * [client] Keep exported Android debug bundles out of the stale cleanup DebugBundleFile hands the zip to the caller, but the file kept the netbird.debug.*.zip name that RemoveStaleBundles matches, so a later debug run could delete it once it was older than an hour. Rename the exported bundle to netbird.debug-file.*.zip after generation so the cleanup only ever touches bundles no caller owns. * [client] Warn when a stale debug bundle cannot be removed A failed removal means bundles pile up in the cache directory, so log it at Warn instead of Debug. A file that is already gone was removed by a concurrent cleanup and is skipped silently. * [client] Drop the outdated debugBundle comment The comment still said the file variant leaves the zip in place, but it is renamed by debug.ExportBundle since the stale-cleanup change. * [client] Test that the network map reaches the debug bundle Cover both halves of the path Android now relies on: the engine keeps the latest sync response once persistence is enabled, and the bundle generator writes it to network_map.json (anonymized or not) and omits the file when there is no sync response. * [client] Remove abandoned exported debug bundles after a day An exported bundle is owned by the caller, but if the app is killed before it copies and deletes the file, nothing ever removes it from the cache directory. Let RemoveStaleBundles also match exported bundles, with a 24 hour max age instead of the caller-provided one, so a bundle that is still being saved survives while an abandoned one goes.
1022 lines
34 KiB
Go
1022 lines
34 KiB
Go
package debug
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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},
|
|
{"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",
|
|
"DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle",
|
|
}
|
|
|
|
mURL, _ := url.Parse("https://api.example.com:443")
|
|
aURL, _ := url.Parse("https://admin.example.com:443")
|
|
bTrue := true
|
|
iVal := 42
|
|
cfg := &profilemanager.Config{
|
|
PrivateKey: "priv",
|
|
PreSharedKey: "psk",
|
|
ManagementURL: mURL,
|
|
AdminURL: aURL,
|
|
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",
|
|
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")
|
|
|
|
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, AdminURL, 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")
|
|
}
|
|
if g.internalConfig.AdminURL != nil {
|
|
sb.WriteString("AdminURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.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")
|
|
}
|
|
if g.internalConfig.AdminURL != nil {
|
|
sb.WriteString("AdminURL: " + g.internalConfig.AdminURL.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())
|
|
}
|
|
|
|
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")
|
|
}
|