mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 20:41:28 +02:00
This introduces a disabled-by-default allow-remote-jobs setting that controls whether the management server may run jobs (such as debug bundles) on a peer. The flag propagates end to end: through client configuration, the daemon SetConfig and Login requests, authentication, and system info, up to management, where it is stored on the peer and exposed on the peers API as remote_jobs_allowed. The client refuses any management-requested job unless the peer has opted in. Because enabling remote jobs crosses the user-to-root boundary, turning it on requires privilege, mirroring the SSH-server gate. Administrators can enforce the setting through MDM policy on both macOS and Windows, and MDM can also override the debug-bundle upload URL. The change ships policy documentation and generated profile templates, and adds configuration, conflict, and enforcement tests covering the opt-in, privilege, and MDM paths.
972 lines
32 KiB
Go
972 lines
32 KiB
Go
package debug
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"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())
|
|
}
|