mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 08:16:39 +00:00
[client] Include service.json in debug bundle (#5825)
* Include service.json in debug bundle * Add tests for service params sanitization logic
This commit is contained in:
@@ -25,6 +25,7 @@ import (
|
|||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/anonymize"
|
"github.com/netbirdio/netbird/client/anonymize"
|
||||||
|
"github.com/netbirdio/netbird/client/configs"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||||
@@ -52,6 +53,7 @@ resolved_domains.txt: Anonymized resolved domain IP addresses from the status re
|
|||||||
config.txt: Anonymized configuration information of the NetBird client.
|
config.txt: Anonymized configuration information of the NetBird client.
|
||||||
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
|
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
|
||||||
state.json: Anonymized client state dump containing netbird states for the active profile.
|
state.json: Anonymized client state dump containing netbird states for the active profile.
|
||||||
|
service_params.json: Sanitized service install parameters (service.json). Sensitive environment variable values are masked. Only present when service.json exists.
|
||||||
metrics.txt: Buffered client metrics in InfluxDB line protocol format. Only present when metrics collection is enabled. Peer identifiers are anonymized.
|
metrics.txt: Buffered client metrics in InfluxDB line protocol format. Only present when metrics collection is enabled. Peer identifiers are anonymized.
|
||||||
mutex.prof: Mutex profiling information.
|
mutex.prof: Mutex profiling information.
|
||||||
goroutine.prof: Goroutine profiling information.
|
goroutine.prof: Goroutine profiling information.
|
||||||
@@ -359,6 +361,10 @@ func (g *BundleGenerator) createArchive() error {
|
|||||||
log.Errorf("failed to add corrupted state files to debug bundle: %v", err)
|
log.Errorf("failed to add corrupted state files to debug bundle: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := g.addServiceParams(); err != nil {
|
||||||
|
log.Errorf("failed to add service params to debug bundle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := g.addMetrics(); err != nil {
|
if err := g.addMetrics(); err != nil {
|
||||||
log.Errorf("failed to add metrics to debug bundle: %v", err)
|
log.Errorf("failed to add metrics to debug bundle: %v", err)
|
||||||
}
|
}
|
||||||
@@ -488,6 +494,90 @@ func (g *BundleGenerator) addConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
serviceParamsFile = "service.json"
|
||||||
|
serviceParamsBundle = "service_params.json"
|
||||||
|
maskedValue = "***"
|
||||||
|
envVarPrefix = "NB_"
|
||||||
|
jsonKeyManagementURL = "management_url"
|
||||||
|
jsonKeyServiceEnv = "service_env_vars"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sensitiveEnvSubstrings = []string{"key", "token", "secret", "password", "credential"}
|
||||||
|
|
||||||
|
// addServiceParams reads the service.json file and adds a sanitized version to the bundle.
|
||||||
|
// Non-NB_ env vars and vars with sensitive names are masked. Other NB_ values are anonymized.
|
||||||
|
func (g *BundleGenerator) addServiceParams() error {
|
||||||
|
path := filepath.Join(configs.StateDir, serviceParamsFile)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read service params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var params map[string]any
|
||||||
|
if err := json.Unmarshal(data, ¶ms); err != nil {
|
||||||
|
return fmt.Errorf("parse service params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.anonymize {
|
||||||
|
if mgmtURL, ok := params[jsonKeyManagementURL].(string); ok && mgmtURL != "" {
|
||||||
|
params[jsonKeyManagementURL] = g.anonymizer.AnonymizeURI(mgmtURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.sanitizeServiceEnvVars(params)
|
||||||
|
|
||||||
|
sanitizedData, err := json.MarshalIndent(params, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal sanitized service params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.addFileToZip(bytes.NewReader(sanitizedData), serviceParamsBundle); err != nil {
|
||||||
|
return fmt.Errorf("add service params to zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeServiceEnvVars masks or anonymizes env var values in service params.
|
||||||
|
// Non-NB_ vars and vars with sensitive names (key, token, etc.) are fully masked.
|
||||||
|
// Other NB_ var values are passed through the anonymizer when anonymization is enabled.
|
||||||
|
func (g *BundleGenerator) sanitizeServiceEnvVars(params map[string]any) {
|
||||||
|
envVars, ok := params[jsonKeyServiceEnv].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := make(map[string]any, len(envVars))
|
||||||
|
for k, v := range envVars {
|
||||||
|
val, _ := v.(string)
|
||||||
|
switch {
|
||||||
|
case !strings.HasPrefix(k, envVarPrefix) || isSensitiveEnvVar(k):
|
||||||
|
sanitized[k] = maskedValue
|
||||||
|
case g.anonymize:
|
||||||
|
sanitized[k] = g.anonymizer.AnonymizeString(val)
|
||||||
|
default:
|
||||||
|
sanitized[k] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params[jsonKeyServiceEnv] = sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSensitiveEnvVar returns true for env var names that may contain secrets.
|
||||||
|
func isSensitiveEnvVar(key string) bool {
|
||||||
|
lower := strings.ToLower(key)
|
||||||
|
for _, s := range sensitiveEnvSubstrings {
|
||||||
|
if strings.Contains(lower, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) {
|
func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) {
|
||||||
configContent.WriteString("NetBird Client Configuration:\n\n")
|
configContent.WriteString("NetBird Client Configuration:\n\n")
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package debug
|
package debug
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -10,6 +14,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/anonymize"
|
"github.com/netbirdio/netbird/client/anonymize"
|
||||||
|
"github.com/netbirdio/netbird/client/configs"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -420,6 +425,226 @@ func TestAnonymizeNetworkMap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Helper function to check if IP is in CGNAT range
|
||||||
func isInCGNATRange(ip net.IP) bool {
|
func isInCGNATRange(ip net.IP) bool {
|
||||||
cgnat := net.IPNet{
|
cgnat := net.IPNet{
|
||||||
|
|||||||
Reference in New Issue
Block a user