[client] Add strict anonymization level and MAC anonymization to debug bundles (#7102)

This commit is contained in:
Viktor Liu
2026-08-10 11:27:20 +02:00
committed by GitHub
parent f65f7b347e
commit 5584f8ef0a
20 changed files with 2068 additions and 1160 deletions
+14 -2
View File
@@ -15,6 +15,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/debug"
@@ -32,6 +33,13 @@ import (
types "github.com/netbirdio/netbird/upload-server/types" types "github.com/netbirdio/netbird/upload-server/types"
) )
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
// anonymizeLevel values for DebugBundle.
const (
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
)
// ConnectionListener export internal Listener for mobile // ConnectionListener export internal Listener for mobile
type ConnectionListener interface { type ConnectionListener interface {
peer.Listener peer.Listener
@@ -278,8 +286,10 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
} }
// DebugBundle generates a debug bundle, uploads it, and returns the upload key. // DebugBundle generates a debug bundle, uploads it, and returns the upload key.
// It works both with and without a running engine. // It works both with and without a running engine. anonymizeLevel is "default"
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) { // or "strict"; strict also anonymizes internal IP ranges, peer names, and
// WireGuard public keys, and implies anonymize.
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
cfg, cacheDir, cc := c.stateSnapshot() cfg, cacheDir, cc := c.stateSnapshot()
// If the engine hasn't been started, load config from disk // If the engine hasn't been started, load config from disk
@@ -298,6 +308,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
InternalConfig: cfg, InternalConfig: cfg,
StatusRecorder: c.recorder, StatusRecorder: c.recorder,
TempDir: cacheDir, TempDir: cacheDir,
StatePath: platformFiles.StateFilePath(),
} }
if cc != nil { if cc != nil {
@@ -321,6 +332,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
deps, deps,
debug.BundleConfig{ debug.BundleConfig{
Anonymize: anonymize, Anonymize: anonymize,
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
IncludeSystemInfo: true, IncludeSystemInfo: true,
}, },
) )
+380 -30
View File
@@ -2,6 +2,7 @@ package anonymize
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64"
"fmt" "fmt"
"math/big" "math/big"
"net" "net"
@@ -15,13 +16,88 @@ import (
const anonTLD = ".domain" const anonTLD = ".domain"
// Level selects how much the anonymizer redacts. Levels are ordered: a higher
// level redacts strictly more. On the wire (protos, flags) levels travel as
// their string form.
type Level int
const (
// LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
// addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
// preserved so support can reason about the real topology.
LevelDefault Level = iota
// LevelStrict additionally anonymizes internal IP ranges, peer names, and
// WireGuard public keys.
LevelStrict
)
// LevelDefaultString and LevelStrictString are the wire forms of the levels,
// for boundaries that pass levels as strings (flags, protos, mobile bindings).
const (
LevelDefaultString = "default"
LevelStrictString = "strict"
)
// ParseLevel maps s to a Level. Empty means LevelDefault; anything
// unrecognized maps to LevelStrict so an unknown request never yields less
// anonymization than intended.
func ParseLevel(s string) Level {
switch strings.ToLower(s) {
case "", LevelDefaultString:
return LevelDefault
default:
return LevelStrict
}
}
// String returns the wire form of the level: "default" or "strict".
func (l Level) String() string {
if l >= LevelStrict {
return LevelStrictString
}
return LevelDefaultString
}
// protectedDomains are NetBird-operated suffixes that stay recognizable in an
// anonymized bundle. At LevelStrict the labels in front of them (the peer
// name) are still replaced, except under netbird.io, which only hosts
// NetBird infrastructure (api, signal, flow), never peer names.
var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
const infraDomain = "netbird.io"
var (
macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
)
type Anonymizer struct { type Anonymizer struct {
ipAnonymizer map[netip.Addr]netip.Addr ipAnonymizer map[netip.Addr]netip.Addr
domainAnonymizer map[string]string domainAnonymizer map[string]string
currentAnonIPv4 netip.Addr // domainOrder caches the keys of domainAnonymizer sorted longest-first
currentAnonIPv6 netip.Addr // for AnonymizeString; it is rebuilt when the map gains entries.
startAnonIPv4 netip.Addr domainOrder []string
startAnonIPv6 netip.Addr labelAnonymizer map[string]string
labelAnonymized map[string]struct{}
labelCounter uint32
macAnonymizer map[string]string
macCounter uint32
wgKeyAnonymizer map[string]string
wgKeyAnonymized map[string]struct{}
currentAnonIPv4 netip.Addr
currentAnonIPv6 netip.Addr
startAnonIPv4 netip.Addr
startAnonIPv6 netip.Addr
// LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT,
// link-local), replacing them from the dedicated internal pools below so
// a reader can still tell an internal address from a public one.
level Level
currentAnonInternalIPv4 netip.Addr
currentAnonInternalIPv6 netip.Addr
startAnonInternalIPv4 netip.Addr
startAnonInternalIPv6 netip.Addr
domainKeyRegex *regexp.Regexp domainKeyRegex *regexp.Regexp
} }
@@ -32,25 +108,50 @@ func DefaultAddresses() (netip.Addr, netip.Addr) {
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::") return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
} }
// InternalAddresses returns the pool starts used in strict mode for internal
// ranges. Both are reserved ranges that cannot collide with real addressing:
// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation).
func InternalAddresses() (netip.Addr, netip.Addr) {
return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::")
}
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer { func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
internalIPv4, internalIPv6 := InternalAddresses()
return &Anonymizer{ return &Anonymizer{
ipAnonymizer: map[netip.Addr]netip.Addr{}, ipAnonymizer: map[netip.Addr]netip.Addr{},
domainAnonymizer: map[string]string{}, domainAnonymizer: map[string]string{},
labelAnonymizer: map[string]string{},
labelAnonymized: map[string]struct{}{},
macAnonymizer: map[string]string{},
wgKeyAnonymizer: map[string]string{},
wgKeyAnonymized: map[string]struct{}{},
currentAnonIPv4: startIPv4, currentAnonIPv4: startIPv4,
currentAnonIPv6: startIPv6, currentAnonIPv6: startIPv6,
startAnonIPv4: startIPv4, startAnonIPv4: startIPv4,
startAnonIPv6: startIPv6, startAnonIPv6: startIPv6,
level: LevelDefault,
currentAnonInternalIPv4: internalIPv4,
currentAnonInternalIPv6: internalIPv6,
startAnonInternalIPv4: internalIPv4,
startAnonInternalIPv6: internalIPv6,
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`), domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`),
} }
} }
// SetLevel selects the anonymization level. The zero value of a new
// Anonymizer is LevelDefault.
func (a *Anonymizer) SetLevel(level Level) {
a.level = level
}
func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
// Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps
// like 192.168.1.1.
ip = ip.Unmap()
if ip.IsLoopback() || if ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
(ip.Is4() && ip.IsPrivate()) ||
ip.IsUnspecified() || ip.IsUnspecified() ||
ip.IsMulticast() || ip.IsMulticast() ||
isWellKnown(ip) || isWellKnown(ip) ||
@@ -59,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
return ip return ip
} }
if isInternal(ip) && a.level < LevelStrict {
return ip
}
if _, ok := a.ipAnonymizer[ip]; !ok { if _, ok := a.ipAnonymizer[ip]; !ok {
if ip.Is4() { a.ipAnonymizer[ip] = a.nextAnonIP(ip)
a.ipAnonymizer[ip] = a.currentAnonIPv4
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
} else {
a.ipAnonymizer[ip] = a.currentAnonIPv6
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
}
} }
return a.ipAnonymizer[ip] return a.ipAnonymizer[ip]
} }
func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr {
// At the strict level, internal addresses (including IPv6 ULA, matched
// by IsPrivate) come from the internal pools so they remain recognizable
// as internal without disclosing the real values.
if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) {
if ip.Is4() {
anon := a.currentAnonInternalIPv4
a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next()
return anon
}
anon := a.currentAnonInternalIPv6
a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next()
return anon
}
if ip.Is4() {
anon := a.currentAnonIPv4
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
return anon
}
anon := a.currentAnonIPv6
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
return anon
}
// AnonymizeMAC replaces a MAC address with a consistent placeholder from the
// locally administered range starting at 02:00:00:00:00:01, at every
// anonymization level. Broadcast, multicast, all-zero, and already assigned
// placeholder addresses are preserved. The colon and dash spellings of the
// same address share one placeholder; the output keeps the input's separator.
func (a *Anonymizer) AnonymizeMAC(mac string) string {
hw, err := net.ParseMAC(mac)
if err != nil || len(hw) != 6 {
return mac
}
if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) {
return mac
}
key := hw.String()
anon, ok := a.macAnonymizer[key]
if !ok {
a.macCounter++
anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter))
a.macAnonymizer[key] = anon
}
if strings.Contains(mac, "-") {
anon = strings.ReplaceAll(anon, ":", "-")
}
return anon
}
// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already
// handed out, so a second pass over anonymized output leaves it unchanged.
func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool {
if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 {
return false
}
value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5])
return value <= a.macCounter
}
// AnonymizeWGKey replaces a WireGuard public key with a consistent random
// placeholder of the same shape. Keys are only anonymized at LevelStrict;
// placeholders already handed out pass through unchanged.
func (a *Anonymizer) AnonymizeWGKey(key string) string {
if a.level < LevelStrict || !looksLikeWGKey(key) {
return key
}
if _, ok := a.wgKeyAnonymized[key]; ok {
return key
}
anon, ok := a.wgKeyAnonymizer[key]
if !ok {
anon = generateAnonymousKey()
a.wgKeyAnonymizer[key] = anon
a.wgKeyAnonymized[anon] = struct{}{}
}
return anon
}
func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr { func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// Convert IP to netip.Addr // Convert IP to netip.Addr
ip, ok := netip.AddrFromSlice(addr.IP) ip, ok := netip.AddrFromSlice(addr.IP)
@@ -89,12 +272,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs // isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool { func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 { if ip.Is4() {
return true return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) ||
} else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 { inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4)
return true
} }
return false return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) ||
inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
} }
func (a *Anonymizer) AnonymizeIPString(ip string) string { func (a *Anonymizer) AnonymizeIPString(ip string) string {
@@ -118,14 +301,17 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
baseDomain = domain[:len(domain)-1] baseDomain = domain[:len(domain)-1]
} }
if strings.HasSuffix(baseDomain, "netbird.io") || if strings.HasSuffix(baseDomain, anonTLD) {
strings.HasSuffix(baseDomain, "netbird.selfhosted") ||
strings.HasSuffix(baseDomain, "netbird.cloud") ||
strings.HasSuffix(baseDomain, "netbird.stage") ||
strings.HasSuffix(baseDomain, anonTLD) {
return domain return domain
} }
if suffix := protectedSuffix(baseDomain); suffix != "" {
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
return domain
}
return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
}
parts := strings.Split(baseDomain, ".") parts := strings.Split(baseDomain, ".")
if len(parts) < 2 { if len(parts) < 2 {
return domain return domain
@@ -141,12 +327,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
} }
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1) result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
if hasDot { if a.level >= LevelStrict && len(parts) > 2 {
result += "." prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup)
result = a.anonymizeLabels(prefix, "host") + "." + anonymized
// The full mapping feeds AnonymizeString so seeded FQDNs are caught
// in log lines as a whole, labels included.
a.domainAnonymizer[baseDomain] = result
}
return withTrailingDot(result, hasDot)
}
// anonymizePeerName replaces the labels in front of a protected suffix with
// numbered peer placeholders, keeping the suffix, and records the full
// mapping for string replacement in logs. The numbering keeps a peer
// recognizable across the whole bundle without disclosing its name.
func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string {
prefix := strings.TrimSuffix(baseDomain, "."+suffix)
result := a.anonymizeLabels(prefix, "peer") + "." + suffix
if result != baseDomain {
a.domainAnonymizer[baseDomain] = result
} }
return result return result
} }
// anonymizeLabels replaces each dot-separated label with a consistent
// numbered placeholder ("<placeholder>-<n>"). Wildcard labels and
// placeholders already handed out pass through unchanged.
func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string {
labels := strings.Split(prefix, ".")
for i, label := range labels {
if label == "*" {
continue
}
if _, ok := a.labelAnonymized[label]; ok {
continue
}
anon, ok := a.labelAnonymizer[label]
if !ok {
a.labelCounter++
anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter)
a.labelAnonymizer[label] = anon
a.labelAnonymized[anon] = struct{}{}
}
labels[i] = anon
}
return strings.Join(labels, ".")
}
func (a *Anonymizer) AnonymizeURI(uri string) string { func (a *Anonymizer) AnonymizeURI(uri string) string {
u, err := url.Parse(uri) u, err := url.Parse(uri)
if err != nil { if err != nil {
@@ -181,16 +408,70 @@ func (a *Anonymizer) AnonymizeString(str string) string {
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
for domain, anonDomain := range a.domainAnonymizer { for _, domain := range a.sortedDomains() {
str = strings.ReplaceAll(str, domain, anonDomain) str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain])
} }
str = a.AnonymizeSchemeURI(str) str = a.AnonymizeSchemeURI(str)
str = a.AnonymizeDNSLogLine(str) str = a.AnonymizeDNSLogLine(str)
// MAC handling runs after the IP passes so preserved IPv6 addresses are
// already out of the way; the separator guard skips matches embedded in a
// longer colon- or dash-separated sequence (such as an IPv6 tail).
str = a.anonymizeMACsInString(str, macColonRegex, ':')
str = a.anonymizeMACsInString(str, macDashRegex, '-')
if a.level >= LevelStrict {
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
}
return str return str
} }
// sortedDomains returns the domain mappings longest-first, so a full-FQDN
// mapping (strict level) is applied before the base-domain mapping it
// contains. The order is rebuilt only when domainAnonymizer has grown.
func (a *Anonymizer) sortedDomains() []string {
if len(a.domainOrder) == len(a.domainAnonymizer) {
return a.domainOrder
}
a.domainOrder = a.domainOrder[:0]
for domain := range a.domainAnonymizer {
a.domainOrder = append(a.domainOrder, domain)
}
slices.SortFunc(a.domainOrder, func(x, y string) int {
if d := len(y) - len(x); d != 0 {
return d
}
return strings.Compare(x, y)
})
return a.domainOrder
}
// anonymizeMACsInString replaces MAC addresses matched by re, skipping
// matches that directly adjoin another sep so a six-group run inside a longer
// separated sequence is left alone.
func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string {
matches := re.FindAllStringIndex(str, -1)
if len(matches) == 0 {
return str
}
var b strings.Builder
last := 0
for _, m := range matches {
if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) {
continue
}
b.WriteString(str[last:m[0]])
b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]]))
last = m[1]
}
b.WriteString(str[last:])
return b.String()
}
// AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes. // AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
func (a *Anonymizer) AnonymizeSchemeURI(text string) string { func (a *Anonymizer) AnonymizeSchemeURI(text string) string {
re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`) re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`)
@@ -239,10 +520,79 @@ func isWellKnown(addr netip.Addr) bool {
"128.0.0.0", "8000::", // 2nd split subnet for default routes "128.0.0.0", "8000::", // 2nd split subnet for default routes
} }
if slices.Contains(wellKnown, addr.String()) { return slices.Contains(wellKnown, addr.String())
}
// isInternal reports whether ip identifies a host only within the local
// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and
// v6). These are preserved at the default level so support can reason about
// the real topology, and replaced from the internal pools at the strict
// level. IPv6 ULA is deliberately not internal: its random global ID uniquely
// fingerprints the network, so it is anonymized at every level.
func isInternal(ip netip.Addr) bool {
return (ip.Is4() && ip.IsPrivate()) ||
ip.IsLinkLocalUnicast() ||
isCGNAT(ip)
}
func inPoolRange(ip, start, current netip.Addr) bool {
return ip.Compare(start) >= 0 && ip.Compare(current) <= 0
}
// isWellKnownMAC reports whether hw carries no stable host identity: all-zero
// or a group address (broadcast and multicast).
func isWellKnownMAC(hw net.HardwareAddr) bool {
if hw[0]&1 == 1 {
return true return true
} }
for _, b := range hw {
if b != 0 {
return false
}
}
return true
}
// looksLikeWGKey reports whether s has the shape of a WireGuard key:
// 44 base64 characters decoding to 32 bytes.
func looksLikeWGKey(s string) bool {
if len(s) != 44 || s[43] != '=' {
return false
}
decoded, err := base64.StdEncoding.DecodeString(s)
return err == nil && len(decoded) == 32
}
func generateAnonymousKey() string {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return strings.Repeat("A", 43) + "="
}
return base64.StdEncoding.EncodeToString(buf)
}
// protectedSuffix returns the protected NetBird suffix baseDomain ends with,
// or empty. The match is label-anchored so an unrelated domain that merely
// ends in the same characters is not preserved.
func protectedSuffix(baseDomain string) string {
for _, d := range protectedDomains {
if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) {
return d
}
}
return ""
}
func withTrailingDot(domain string, hasDot bool) string {
if hasDot {
return domain + "."
}
return domain
}
// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range
// NetBird assigns overlay peer addresses from.
func isCGNAT(addr netip.Addr) bool {
cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0}) cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10) cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)
+298
View File
@@ -1,8 +1,11 @@
package anonymize_test package anonymize_test
import ( import (
"bytes"
"encoding/base64"
"net/netip" "net/netip"
"regexp" "regexp"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) {
} }
} }
func TestParseLevel(t *testing.T) {
tests := []struct {
input string
expect anonymize.Level
}{
{"", anonymize.LevelDefault},
{"default", anonymize.LevelDefault},
{"DEFAULT", anonymize.LevelDefault},
{"strict", anonymize.LevelStrict},
{"STRICT", anonymize.LevelStrict},
// Unknown values must never yield less anonymization than requested.
{"garbage", anonymize.LevelStrict},
}
for _, tc := range tests {
t.Run("input="+tc.input, func(t *testing.T) {
assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match")
})
}
}
func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
tests := []struct {
name string
ip string
expect string
}{
{"RFC1918 10/8", "10.1.2.3", "10.1.2.3"},
{"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"},
{"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"},
{"CGNAT", "100.64.0.5", "100.64.0.5"},
{"IPv4 link-local", "169.254.1.1", "169.254.1.1"},
{"IPv6 link-local", "fe80::1", "fe80::1"},
// ULA is anonymized even at the default level: its random global ID
// uniquely fingerprints the network, unlike shared RFC 1918 space.
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"},
// 4-in-6 addresses classify like their unmapped IPv4 form.
{"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"},
{"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA")
})
}
}
func TestAnonymizeIP_StrictLevel(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
// Order matters: internal pool addresses are assigned sequentially.
tests := []struct {
name string
ip string
expect string
}{
{"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"},
{"Second RFC1918", "192.168.1.2", "198.18.0.1"},
{"Repeated RFC1918", "192.168.1.1", "198.18.0.0"},
{"RFC1918 10/8", "10.1.2.3", "198.18.0.2"},
{"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"},
{"CGNAT", "100.64.0.5", "198.18.0.4"},
{"IPv4 link-local", "169.254.1.1", "198.18.0.5"},
{"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"},
{"IPv6 link-local", "fe80::1", "2001:db8:1::"},
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"},
{"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
{"Loopback IPv4", "127.0.0.1", "127.0.0.1"},
{"Loopback IPv6", "::1", "::1"},
{"Unspecified", "0.0.0.0", "0.0.0.0"},
{"Multicast", "224.0.0.251", "224.0.0.251"},
{"Well known resolver", "8.8.8.8", "8.8.8.8"},
{"Well known split marker", "128.0.0.0", "128.0.0.0"},
{"In internal pool range", "198.18.0.3", "198.18.0.3"},
{"In public pool range", "198.51.100.0", "198.51.100.0"},
{"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools")
})
}
}
func TestAnonymizeString_StrictInternalIPs(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7"
firstPass := anonymizer.AnonymizeString(input)
secondPass := anonymizer.AnonymizeString(firstPass)
assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized")
assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized")
assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized")
assert.Contains(t, firstPass, "/24", "prefix length should be preserved")
assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string")
}
func TestAnonymizeMAC(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f")
assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder")
assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder")
assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping")
assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping")
second := anonymizer.AnonymizeMAC("10:22:33:44:55:66")
assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder")
tests := []struct {
name string
mac string
}{
{"Broadcast", "ff:ff:ff:ff:ff:ff"},
{"IPv4 multicast", "01:00:5e:00:00:fb"},
{"IPv6 multicast", "33:33:00:00:00:01"},
{"All zero", "00:00:00:00:00:00"},
{"Assigned placeholder", "02:00:00:00:00:01"},
{"Invalid", "not-a-mac"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved")
})
}
}
func TestAnonymizeString_MACAddresses(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
tests := []struct {
name string
input string
expect string
}{
{
name: "nftables ether rule",
input: "ether saddr aa:bb:cc:dd:ee:ff drop",
expect: "ether saddr 02:00:00:00:00:01 drop",
},
{
name: "Windows dash form",
input: "Physical Address : AA-BB-CC-DD-EE-FF",
expect: "Physical Address : 02-00-00-00-00-01",
},
{
name: "IPv6 address tail is not treated as MAC",
input: "addr fe80:0:11:22:33:44:55:66 scope link",
expect: "addr fe80:0:11:22:33:44:55:66 scope link",
},
{
name: "broadcast MAC preserved",
input: "dst ff:ff:ff:ff:ff:ff type ARP",
expect: "dst ff:ff:ff:ff:ff:ff type ARP",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeString(tc.input)
assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level")
assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result")
})
}
}
func TestAnonymizeWGKey(t *testing.T) {
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
t.Run("default level preserves keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys")
})
t.Run("strict level replaces keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
anon := anonymizer.AnonymizeWGKey(key)
assert.NotEqual(t, key, anon, "strict level should replace the key")
assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape")
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder")
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged")
assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved")
})
}
func TestAnonymizeString_WGKeys(t *testing.T) {
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
input := "peer " + key + " handshake completed"
t.Run("default level preserves keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings")
})
t.Run("strict level replaces keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
firstPass := anonymizer.AnonymizeString(input)
assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization")
assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey")
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
})
}
func extractKey(t *testing.T, logLine string) string {
t.Helper()
fields := strings.Fields(logLine)
require.Len(t, fields, 4, "log line should keep its structure")
return fields[1]
}
func TestAnonymizeDomain_StrictLevel(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
t.Run("netbird peer name", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud")
assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept")
assert.NotContains(t, result, "my-laptop", "the peer name should not survive")
assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently")
assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged")
})
t.Run("bare netbird domain", func(t *testing.T) {
assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved")
})
t.Run("netbird infrastructure preserved", func(t *testing.T) {
assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"),
"netbird.io hosts infrastructure, not peer names, and should stay readable")
})
t.Run("leading labels of other domains", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("host1.corp.example.com")
assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized")
for _, label := range []string{"host1", "corp", "example"} {
assert.NotContains(t, result, label, "no original label should survive")
}
assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently")
})
t.Run("same label maps consistently across domains", func(t *testing.T) {
first := anonymizer.AnonymizeDomain("shared.one.com")
second := anonymizer.AnonymizeDomain("shared.two.com")
assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder")
})
t.Run("wildcard label preserved", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("*.example.com")
assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard")
})
}
func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"),
"default level should preserve netbird FQDNs including the peer name")
assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"),
"default level should keep subdomain labels")
}
func TestAnonymizeString_StrictPeerNames(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
// Seed like the bundle generator does from the status: base first, then
// the full FQDN, so replacement must prefer the longer mapping.
anonBase := anonymizer.AnonymizeDomain("example.com")
anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud")
anonHost := anonymizer.AnonymizeDomain("host1.example.com")
logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint"
firstPass := anonymizer.AnonymizeString(logLine)
assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs")
assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs")
assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied")
assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping")
assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping")
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
}
func TestAnonymizeDNSLogLine(t *testing.T) { func TestAnonymizeDNSLogLine(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
tests := []struct { tests := []struct {
+22 -10
View File
@@ -27,8 +27,8 @@ import (
const errCloseConnection = "Failed to close connection: %v" const errCloseConnection = "Failed to close connection: %v"
var ( var (
logFileCount uint32 logFileCount uint32
systemInfoFlag bool systemInfoFlag bool
uploadBundleFlag bool uploadBundleFlag bool
uploadBundleURLFlag string uploadBundleURLFlag string
uploadBundleInsecureFlag bool uploadBundleInsecureFlag bool
@@ -156,6 +156,11 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
// request. Returns an error if the RPC fails or if the daemon reports // request. Returns an error if the RPC fails or if the daemon reports
// an upload failure reason. // an upload failure reason.
func debugBundle(cmd *cobra.Command, _ []string) error { func debugBundle(cmd *cobra.Command, _ []string) error {
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
if err != nil {
return err
}
conn, err := getClient(cmd) conn, err := getClient(cmd)
if err != nil { if err != nil {
return err return err
@@ -168,10 +173,11 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
client := proto.NewDaemonServiceClient(conn) client := proto.NewDaemonServiceClient(conn)
request := &proto.DebugBundleRequest{ request := &proto.DebugBundleRequest{
Anonymize: anonymizeFlag, Anonymize: anonymizeEnabled,
SystemInfo: systemInfoFlag, AnonymizeLevel: anonymizeLevel.String(),
LogFileCount: logFileCount, SystemInfo: systemInfoFlag,
CliVersion: version.NetbirdVersion(), LogFileCount: logFileCount,
CliVersion: version.NetbirdVersion(),
} }
if uploadBundleFlag { if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag request.UploadURL = uploadBundleURLFlag
@@ -229,6 +235,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid duration format: %v", err) return fmt.Errorf("invalid duration format: %v", err)
} }
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
if err != nil {
return err
}
conn, err := getClient(cmd) conn, err := getClient(cmd)
if err != nil { if err != nil {
return err return err
@@ -368,10 +379,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
cmd.Println("Creating debug bundle...") cmd.Println("Creating debug bundle...")
request := &proto.DebugBundleRequest{ request := &proto.DebugBundleRequest{
Anonymize: anonymizeFlag, Anonymize: anonymizeEnabled,
SystemInfo: systemInfoFlag, AnonymizeLevel: anonymizeLevel.String(),
LogFileCount: logFileCount, SystemInfo: systemInfoFlag,
CliVersion: version.NetbirdVersion(), LogFileCount: logFileCount,
CliVersion: version.NetbirdVersion(),
} }
if uploadBundleFlag { if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag request.UploadURL = uploadBundleURLFlag
+17 -1
View File
@@ -21,6 +21,7 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
"google.golang.org/grpc" "google.golang.org/grpc"
"github.com/netbirdio/netbird/client/anonymize"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/profilemanager"
) )
@@ -69,6 +70,7 @@ var (
autoConnectDisabled bool autoConnectDisabled bool
extraIFaceBlackList []string extraIFaceBlackList []string
anonymizeFlag bool anonymizeFlag bool
anonymizeLevelFlag string
dnsRouteInterval time.Duration dnsRouteInterval time.Duration
// lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection // lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
// flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead). // flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
@@ -156,7 +158,8 @@ func init() {
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file") rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file")
rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.") rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.")
rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device") rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device")
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output") rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)")
rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize")
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location") rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location")
rootCmd.AddCommand(upCmd) rootCmd.AddCommand(upCmd)
@@ -293,6 +296,19 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{
Clock: backoff.SystemClock, Clock: backoff.SystemClock,
} }
// effectiveAnonymize resolves the --anonymize and --anonymize-level flags:
// setting a level implies anonymization, and an invalid level is rejected.
func effectiveAnonymize() (bool, anonymize.Level, error) {
if anonymizeLevelFlag == "" {
return anonymizeFlag, anonymize.LevelDefault, nil
}
level := anonymize.ParseLevel(anonymizeLevelFlag)
if !strings.EqualFold(anonymizeLevelFlag, level.String()) {
return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String())
}
return true, level, nil
}
func getSetupKey() (string, error) { func getSetupKey() (string, error) {
if setupKeyPath != "" && setupKey == "" { if setupKeyPath != "" && setupKey == "" {
return getSetupKeyFromFile(setupKeyPath) return getSetupKeyFromFile(setupKeyPath)
+7 -1
View File
@@ -121,8 +121,14 @@ func statusFunc(cmd *cobra.Command, args []string) error {
sessionExpiresAt = ts.AsTime().UTC() sessionExpiresAt = ts.AsTime().UTC()
} }
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
if err != nil {
return err
}
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
Anonymize: anonymizeFlag, Anonymize: anonymizeEnabled,
AnonymizeLevel: anonymizeLevel,
DaemonVersion: resp.GetDaemonVersion(), DaemonVersion: resp.GetDaemonVersion(),
DaemonStatus: nbstatus.ParseDaemonStatus(status), DaemonStatus: nbstatus.ParseDaemonStatus(status),
StatusFilter: statusFilter, StatusFilter: statusFilter,
+76 -15
View File
@@ -34,9 +34,8 @@ import (
"github.com/netbirdio/netbird/shared/netiputil" "github.com/netbirdio/netbird/shared/netiputil"
) )
const readmeContent = `Netbird debug bundle const readmeContent = `This debug bundle contains the following files.
This debug bundle contains the following files. If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information.
If the --anonymize flag is set, the files are anonymized to protect sensitive information.
status.txt: Anonymized status information of the NetBird client. status.txt: Anonymized status information of the NetBird client.
client.log: Most recent, anonymized client log file of the NetBird client. client.log: Most recent, anonymized client log file of the NetBird client.
@@ -70,21 +69,34 @@ capture.pcap: Packet capture in pcap format. Only present when capture was runni
Anonymization Process Anonymization Process
The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied: The files in this bundle have been anonymized to protect sensitive information. The level applied to this bundle is recorded at the top of this file. Here's how the anonymization was applied:
IP Addresses IP Addresses
IPv4 addresses are replaced with addresses starting from 198.51.100.0 Default level:
IPv6 addresses are replaced with addresses starting from 100:: - Public IPv4 addresses are replaced with addresses starting from 198.51.100.0
- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff::
- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network.
- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::).
Strict level (--anonymize-level strict), in addition to the default level:
- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too.
- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones.
- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved.
- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders.
- WireGuard public keys are replaced with consistent placeholder keys.
IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.).
Reoccuring IP addresses are replaced with the same anonymized address. Reoccuring IP addresses are replaced with the same anonymized address.
Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files. Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files.
MAC Addresses
MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses.
Domains Domains
All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle. All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle.
Reoccuring domain names are replaced with the same anonymized domain. Reoccuring domain names are replaced with the same anonymized domain.
At the strict level, the peer name labels in front of netbird domains are anonymized as well.
Sync Response Sync Response
The network_map.json file contains the following anonymized information: The network_map.json file contains the following anonymized information:
@@ -281,6 +293,7 @@ type BundleGenerator struct {
cliVersion string cliVersion string
anonymize bool anonymize bool
anonymizeLevel anonymize.Level
includeSystemInfo bool includeSystemInfo bool
logFileCount uint32 logFileCount uint32
@@ -288,7 +301,10 @@ type BundleGenerator struct {
} }
type BundleConfig struct { type BundleConfig struct {
Anonymize bool Anonymize bool
// AnonymizeLevel selects how much the anonymizer redacts.
// anonymize.LevelStrict implies Anonymize.
AnonymizeLevel anonymize.Level
IncludeSystemInfo bool IncludeSystemInfo bool
LogFileCount uint32 LogFileCount uint32
} }
@@ -327,8 +343,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
uiLogOpener = openLogFile uiLogOpener = openLogFile
} }
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(cfg.AnonymizeLevel)
return &BundleGenerator{ return &BundleGenerator{
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), anonymizer: anonymizer,
internalConfig: deps.InternalConfig, internalConfig: deps.InternalConfig,
statusRecorder: deps.StatusRecorder, statusRecorder: deps.StatusRecorder,
@@ -345,7 +364,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
daemonVersion: deps.DaemonVersion, daemonVersion: deps.DaemonVersion,
cliVersion: deps.CliVersion, cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize, anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict,
anonymizeLevel: cfg.AnonymizeLevel,
includeSystemInfo: cfg.IncludeSystemInfo, includeSystemInfo: cfg.IncludeSystemInfo,
logFileCount: logFileCount, logFileCount: logFileCount,
} }
@@ -485,7 +505,13 @@ func (g *BundleGenerator) addSystemInfo() {
} }
func (g *BundleGenerator) addReadme() error { func (g *BundleGenerator) addReadme() error {
readmeReader := strings.NewReader(readmeContent) level := "none (anonymization disabled)"
if g.anonymize {
level = g.anonymizeLevel.String()
}
header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level)
readmeReader := strings.NewReader(header + readmeContent)
if err := g.addFileToZip(readmeReader, "README.txt"); err != nil { if err := g.addFileToZip(readmeReader, "README.txt"); err != nil {
return fmt.Errorf("add README file to zip: %w", err) return fmt.Errorf("add README file to zip: %w", err)
} }
@@ -507,9 +533,10 @@ func (g *BundleGenerator) addStatus() error {
fullStatus := g.statusRecorder.GetFullStatus() fullStatus := g.statusRecorder.GetFullStatus()
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus) protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{ overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
Anonymize: g.anonymize, Anonymize: g.anonymize,
ProfileName: profName, AnonymizeLevel: g.anonymizeLevel,
DaemonVersion: g.daemonVersion, ProfileName: profName,
DaemonVersion: g.daemonVersion,
}) })
overview.CliVersion = g.cliVersion overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary() statusOutput := overview.FullDetailSummary()
@@ -662,7 +689,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString("NetBird Client Configuration:\n\n") configContent.WriteString("NetBird Client Configuration:\n\n")
if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil { if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil {
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String())) configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String())))
} }
configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface)) configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface))
configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort)) configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort))
@@ -952,6 +979,11 @@ func (g *BundleGenerator) addUpdateLogs() error {
} }
baseName := filepath.Base(logFile) baseName := filepath.Base(logFile)
data, err = g.anonymizeBytes(data)
if err != nil {
log.Warnf("skipping update log file %s: %v", baseName, err)
continue
}
if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil { if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil {
return fmt.Errorf("add update log file %s to zip: %w", baseName, err) return fmt.Errorf("add update log file %s to zip: %w", baseName, err)
} }
@@ -979,6 +1011,13 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
} }
fileName := filepath.Base(match) fileName := filepath.Base(match)
// Corrupted state files usually fail structured JSON anonymization,
// so run them through the string anonymizer instead.
data, err = g.anonymizeBytes(data)
if err != nil {
log.Warnf("skipping corrupted state file %s: %v", fileName, err)
continue
}
if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil { if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil {
log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err) log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err)
continue continue
@@ -990,6 +1029,27 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
return nil return nil
} }
// anonymizeBytes runs raw file content through the string anonymizer line by
// line when anonymization is enabled. It errors instead of returning partial
// content, so a caller never adds an unanonymized fallback to the bundle.
func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) {
if !g.anonymize {
return data, nil
}
var buf bytes.Buffer
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text()))
buf.WriteByte('\n')
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("anonymize content: %w", err)
}
return buf.Bytes(), nil
}
func (g *BundleGenerator) addMetrics() error { func (g *BundleGenerator) addMetrics() error {
if g.clientMetrics == nil { if g.clientMetrics == nil {
log.Debugf("skipping metrics in debug bundle: no metrics collector") log.Debugf("skipping metrics in debug bundle: no metrics collector")
@@ -1462,6 +1522,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize.
} }
peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn) peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn)
peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey)
anonymizeSSHConfig(peer.SshConfig) anonymizeSSHConfig(peer.SshConfig)
} }
+7 -3
View File
@@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName)) sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName))
sb.WriteString(fmt.Sprintf(" public key: %s\n", s.PublicKey)) sb.WriteString(fmt.Sprintf(" public key: %s\n", g.anonymizer.AnonymizeWGKey(s.PublicKey)))
sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort)) sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort))
if s.FWMark != 0 { if s.FWMark != 0 {
sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark)) sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark))
} }
for _, peer := range s.Peers { for _, peer := range s.Peers {
sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey)) sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey)))
if peer.Endpoint.IP != nil { if peer.Endpoint.IP != nil {
if g.anonymize { if g.anonymize {
anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint) anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint)
@@ -54,7 +54,11 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
if len(peer.AllowedIPs) > 0 { if len(peer.AllowedIPs) > 0 {
var ipStrings []string var ipStrings []string
for _, ipnet := range peer.AllowedIPs { for _, ipnet := range peer.AllowedIPs {
ipStrings = append(ipStrings, ipnet.String()) ipStr := ipnet.String()
if g.anonymize {
ipStr = g.anonymizer.AnonymizeIPString(ipStr)
}
ipStrings = append(ipStrings, ipStr)
} }
sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", "))) sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", ")))
} }
+2
View File
@@ -23,6 +23,7 @@ import (
"golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/anonymize"
nberrors "github.com/netbirdio/netbird/client/errors" nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall" "github.com/netbirdio/netbird/client/firewall"
"github.com/netbirdio/netbird/client/firewall/firewalld" "github.com/netbirdio/netbird/client/firewall/firewalld"
@@ -1385,6 +1386,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
bundleJobParams := debug.BundleConfig{ bundleJobParams := debug.BundleConfig{
Anonymize: params.Anonymize, Anonymize: params.Anonymize,
AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel),
IncludeSystemInfo: true, IncludeSystemInfo: true,
LogFileCount: uint32(params.LogFileCount), LogFileCount: uint32(params.LogFileCount),
} }
+13 -2
View File
@@ -14,6 +14,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/debug"
@@ -28,6 +29,13 @@ import (
types "github.com/netbirdio/netbird/upload-server/types" types "github.com/netbirdio/netbird/upload-server/types"
) )
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
// anonymizeLevel values for DebugBundle.
const (
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
)
// ConnectionListener export internal Listener for mobile // ConnectionListener export internal Listener for mobile
type ConnectionListener interface { type ConnectionListener interface {
peer.Listener peer.Listener
@@ -200,8 +208,10 @@ func (c *Client) Stop() {
// DebugBundle generates a debug bundle, uploads it and returns the upload key. // DebugBundle generates a debug bundle, uploads it and returns the upload key.
// It works with or without a running engine: when the engine is up it reuses // It works with or without a running engine: when the engine is up it reuses
// the live config, sync response and client metrics; otherwise it loads the // the live config, sync response and client metrics; otherwise it loads the
// config from disk (or the preloaded tvOS config). // config from disk (or the preloaded tvOS config). anonymizeLevel is "default"
func (c *Client) DebugBundle(anonymize bool) (string, error) { // or "strict"; strict also anonymizes internal IP ranges, peer names, and
// WireGuard public keys, and implies anonymize.
func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) {
cfg, cc := c.stateSnapshot() cfg, cc := c.stateSnapshot()
// If the engine hasn't been started, load config so we can reach management. // If the engine hasn't been started, load config so we can reach management.
@@ -251,6 +261,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
deps, deps,
debug.BundleConfig{ debug.BundleConfig{
Anonymize: anonymize, Anonymize: anonymize,
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
IncludeSystemInfo: true, IncludeSystemInfo: true,
}, },
) )
+15 -2
View File
@@ -2781,6 +2781,11 @@ type DebugBundleRequest struct {
// untrusted TLS certificate. Restricted to privileged callers; for // untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers. // self-hosted upload servers.
UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"` UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"`
// anonymizeLevel selects how much the anonymizer redacts: "default"
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
// Unknown values are treated as "strict". Only meaningful with anonymize;
// "strict" implies it.
AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -2857,6 +2862,13 @@ func (x *DebugBundleRequest) GetUploadInsecure() bool {
return false return false
} }
func (x *DebugBundleRequest) GetAnonymizeLevel() string {
if x != nil {
return x.AnonymizeLevel
}
return ""
}
type DebugBundleResponse struct { type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -7253,7 +7265,7 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" + "\x17ForwardingRulesResponse\x12,\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" +
"\x12DebugBundleRequest\x12\x1c\n" + "\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" + "\n" +
@@ -7264,7 +7276,8 @@ const file_daemon_proto_rawDesc = "" +
"\n" + "\n" +
"cliVersion\x18\x06 \x01(\tR\n" + "cliVersion\x18\x06 \x01(\tR\n" +
"cliVersion\x12&\n" + "cliVersion\x12&\n" +
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" + "\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" +
"\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" + "\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" + "\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" + "\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
+5
View File
@@ -540,6 +540,11 @@ message DebugBundleRequest {
// untrusted TLS certificate. Restricted to privileged callers; for // untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers. // self-hosted upload servers.
bool uploadInsecure = 7; bool uploadInsecure = 7;
// anonymizeLevel selects how much the anonymizer redacts: "default"
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
// Unknown values are treated as "strict". Only meaningful with anonymize;
// "strict" implies it.
string anonymizeLevel = 8;
} }
message DebugBundleResponse { message DebugBundleResponse {
+2
View File
@@ -16,6 +16,7 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status" gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/proto"
@@ -122,6 +123,7 @@ func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener deb
}, },
debug.BundleConfig{ debug.BundleConfig{
Anonymize: req.GetAnonymize(), Anonymize: req.GetAnonymize(),
AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()),
IncludeSystemInfo: req.GetSystemInfo(), IncludeSystemInfo: req.GetSystemInfo(),
LogFileCount: req.GetLogFileCount(), LogFileCount: req.GetLogFileCount(),
}, },
+7 -1
View File
@@ -46,7 +46,10 @@ func ParseDaemonStatus(s string) DaemonStatus {
// ConvertOptions holds parameters for ConvertToStatusOutputOverview. // ConvertOptions holds parameters for ConvertToStatusOutputOverview.
type ConvertOptions struct { type ConvertOptions struct {
Anonymize bool Anonymize bool
// AnonymizeLevel selects how much the anonymizer redacts. Only
// meaningful when Anonymize is set.
AnonymizeLevel anonymize.Level
DaemonVersion string DaemonVersion string
DaemonStatus DaemonStatus DaemonStatus DaemonStatus
StatusFilter string StatusFilter string
@@ -217,6 +220,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
if opts.Anonymize { if opts.Anonymize {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(opts.AnonymizeLevel)
anonymizeOverview(anonymizer, &overview) anonymizeOverview(anonymizer, &overview)
} }
@@ -976,6 +980,7 @@ func timeAgo(t time.Time) string {
func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) { func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) {
peer.FQDN = a.AnonymizeDomain(peer.FQDN) peer.FQDN = a.AnonymizeDomain(peer.FQDN)
peer.PubKey = a.AnonymizeWGKey(peer.PubKey)
if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil { if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil {
peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port) peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port)
} }
@@ -1007,6 +1012,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL) overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL)
overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error) overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error)
overview.PubKey = a.AnonymizeWGKey(overview.PubKey)
overview.IP = a.AnonymizeIPString(overview.IP) overview.IP = a.AnonymizeIPString(overview.IP)
overview.IPv6 = a.AnonymizeIPString(overview.IPv6) overview.IPv6 = a.AnonymizeIPString(overview.IPv6)
for i, detail := range overview.Relays.Details { for i, detail := range overview.Relays.Details {
@@ -71,10 +71,12 @@ type BundleOptions = {
hasWindow: boolean; hasWindow: boolean;
totalSec: number; totalSec: number;
uploadUrl: string; uploadUrl: string;
anonymize: boolean; anonymizeLevel: AnonymizeLevel;
systemInfo: boolean; systemInfo: boolean;
}; };
export type AnonymizeLevel = "none" | "default" | "strict";
const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => { const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
try { try {
// Mirror the CLI's safety margin: window + 30s, server caps at 10m. // Mirror the CLI's safety margin: window + 30s, server caps at 10m.
@@ -187,7 +189,10 @@ const runBundleFlow = async (
if (opts.uploadUrl) setStage({ kind: "uploading" }); if (opts.uploadUrl) setStage({ kind: "uploading" });
const result = await DebugSvc.Bundle({ const result = await DebugSvc.Bundle({
anonymize: opts.anonymize, anonymize: opts.anonymizeLevel !== "none",
// The daemon only knows "default" and "strict"; "none" is expressed
// through the anonymize flag being off.
anonymizeLevel: opts.anonymizeLevel === "strict" ? "strict" : "default",
systemInfo: opts.systemInfo, systemInfo: opts.systemInfo,
uploadUrl: opts.uploadUrl, uploadUrl: opts.uploadUrl,
logFileCount, logFileCount,
@@ -198,7 +203,7 @@ const runBundleFlow = async (
}; };
const useDebugBundle = () => { const useDebugBundle = () => {
const [anonymize, setAnonymize] = useState(false); const [anonymizeLevel, setAnonymizeLevel] = useState<AnonymizeLevel>("none");
const [systemInfo, setSystemInfo] = useState(true); const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(true); const [upload, setUpload] = useState(true);
const [trace, setTrace] = useState(true); const [trace, setTrace] = useState(true);
@@ -240,7 +245,7 @@ const useDebugBundle = () => {
hasWindow: capture && totalSec > 0, hasWindow: capture && totalSec > 0,
totalSec, totalSec,
uploadUrl: upload ? NETBIRD_UPLOAD_URL : "", uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
anonymize, anonymizeLevel,
systemInfo, systemInfo,
}; };
@@ -272,8 +277,8 @@ const useDebugBundle = () => {
}; };
return { return {
anonymize, anonymizeLevel,
setAnonymize, setAnonymizeLevel,
systemInfo, systemInfo,
setSystemInfo, setSystemInfo,
upload, upload,
@@ -1,6 +1,6 @@
import { useId, type ReactNode } from "react"; import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react"; import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react";
import { Browser } from "@wailsio/runtime"; import { Browser } from "@wailsio/runtime";
import { Debug as DebugSvc } from "@bindings/services"; import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js"; import type { DebugBundleResult } from "@bindings/services/models.js";
@@ -8,13 +8,22 @@ import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions"; import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription"; import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading"; import { DialogHeading } from "@/components/dialog/DialogHeading";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx"; import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input"; import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label"; import { Label } from "@/components/typography/Label";
import { SquareIcon } from "@/components/SquareIcon"; import { SquareIcon } from "@/components/SquareIcon";
import { Tooltip } from "@/components/Tooltip";
import { cn } from "@/lib/cn";
import { formatRemaining } from "@/lib/formatters"; import { formatRemaining } from "@/lib/formatters";
import type { DebugStage } from "@/contexts/DebugBundleContext"; import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext"; import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
@@ -24,8 +33,8 @@ export function SettingsTroubleshooting() {
const { t } = useTranslation(); const { t } = useTranslation();
const durationId = useId(); const durationId = useId();
const { const {
anonymize, anonymizeLevel,
setAnonymize, setAnonymizeLevel,
systemInfo, systemInfo,
setSystemInfo, setSystemInfo,
upload, upload,
@@ -55,12 +64,71 @@ export function SettingsTroubleshooting() {
return ( return (
<SectionGroup title={t("settings.troubleshooting.section.title")}> <SectionGroup title={t("settings.troubleshooting.section.title")}>
<FancyToggleSwitch <div className={"flex items-center justify-between gap-6"}>
value={anonymize} <div className={"max-w-md flex-1"}>
onChange={setAnonymize} <Label as={"div"}>
label={t("settings.troubleshooting.anonymize.label")} <span className={"inline-flex items-center gap-1.5"}>
helpText={t("settings.troubleshooting.anonymize.help")} {t("settings.troubleshooting.anonymize.label")}
/> <Tooltip
content={
<div className={"max-w-xs whitespace-normal leading-relaxed"}>
{t("settings.troubleshooting.anonymize.info")}
</div>
}
>
<Info
size={14}
aria-label={t("settings.troubleshooting.anonymize.label")}
className={"shrink-0 cursor-default text-nb-gray-400"}
/>
</Tooltip>
</span>
</Label>
<HelpText margin={false}>
{t("settings.troubleshooting.anonymize.help")}
</HelpText>
</div>
<div className={"shrink-0"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={t("settings.troubleshooting.anonymize.label")}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
)}
>
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
<ChevronDown
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
<DropdownMenuRadioGroup
value={anonymizeLevel}
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
>
<DropdownMenuRadioItem value={"none"}>
{t("settings.troubleshooting.anonymize.none")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"default"}>
{t("settings.troubleshooting.anonymize.default")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"strict"}>
{t("settings.troubleshooting.anonymize.strict")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<FancyToggleSwitch <FancyToggleSwitch
value={systemInfo} value={systemInfo}
onChange={setSystemInfo} onChange={setSystemInfo}
+19 -3
View File
@@ -1013,11 +1013,27 @@
}, },
"settings.troubleshooting.anonymize.label": { "settings.troubleshooting.anonymize.label": {
"message": "Anonymize Sensitive Information", "message": "Anonymize Sensitive Information",
"description": "Toggle label: anonymize sensitive information in the bundle." "description": "Label for the anonymization level dropdown (None, Default, Strict)."
}, },
"settings.troubleshooting.anonymize.help": { "settings.troubleshooting.anonymize.help": {
"message": "Hides public IP addresses and non-NetBird domains from logs.", "message": "Hides IP addresses, domains, and other sensitive values.",
"description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)." "description": "Helper text under the anonymization dropdown. The level details live in the info tooltip."
},
"settings.troubleshooting.anonymize.info": {
"message": "Default keeps internal IPv4 addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.",
"description": "Info tooltip explaining the anonymization levels. 'RFC 1918', 'CGNAT', 'link-local', and 'WireGuard' are technical terms — keep them."
},
"settings.troubleshooting.anonymize.none": {
"message": "None",
"description": "Dropdown option: no anonymization."
},
"settings.troubleshooting.anonymize.default": {
"message": "Default",
"description": "Dropdown option: default anonymization level."
},
"settings.troubleshooting.anonymize.strict": {
"message": "Strict",
"description": "Dropdown option: strict anonymization level."
}, },
"settings.troubleshooting.systemInfo.label": { "settings.troubleshooting.systemInfo.label": {
"message": "Include System Information", "message": "Include System Information",
+13 -9
View File
@@ -15,10 +15,13 @@ import (
) )
type DebugBundleParams struct { type DebugBundleParams struct {
Anonymize bool `json:"anonymize"` Anonymize bool `json:"anonymize"`
SystemInfo bool `json:"systemInfo"` // AnonymizeLevel is "default" or "strict"; strict also anonymizes
UploadURL string `json:"uploadUrl"` // private IP ranges, peer names, and WireGuard public keys.
LogFileCount uint32 `json:"logFileCount"` AnonymizeLevel string `json:"anonymizeLevel"`
SystemInfo bool `json:"systemInfo"`
UploadURL string `json:"uploadUrl"`
LogFileCount uint32 `json:"logFileCount"`
} }
// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload // DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload
@@ -48,11 +51,12 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
return DebugBundleResult{}, err return DebugBundleResult{}, err
} }
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{ resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
Anonymize: p.Anonymize, Anonymize: p.Anonymize,
SystemInfo: p.SystemInfo, AnonymizeLevel: p.AnonymizeLevel,
UploadURL: p.UploadURL, SystemInfo: p.SystemInfo,
LogFileCount: p.LogFileCount, UploadURL: p.UploadURL,
CliVersion: version.NetbirdVersion(), LogFileCount: p.LogFileCount,
CliVersion: version.NetbirdVersion(),
}) })
if err != nil { if err != nil {
return DebugBundleResult{}, err return DebugBundleResult{}, err
File diff suppressed because it is too large Load Diff
+4
View File
@@ -110,6 +110,10 @@ message BundleParameters {
int64 bundle_for_time = 2; int64 bundle_for_time = 2;
int32 log_file_count = 3; int32 log_file_count = 3;
bool anonymize = 4; bool anonymize = 4;
// anonymize_level selects how much the anonymizer redacts: "default"
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
// Unknown values are treated as "strict".
string anonymize_level = 5;
} }
message BundleResult { message BundleResult {