mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 19:29:08 +02:00
Add profile owners to debug bundle
This commit is contained in:
@@ -54,6 +54,8 @@ scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-in
|
||||
dns_windows.txt: Anonymized NRPT rules and policy table in effect, DNS client policy, and per-interface and per-adapter DNS configuration (Windows only), if --system-info flag was provided.
|
||||
resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder.
|
||||
config.txt: Anonymized configuration information of the NetBird client.
|
||||
profiles.txt: Inventory of the profiles stored on this device, one block per profile: profile ID (the config filename stem), display name, path, whether it was the active profile when the bundle was created, and the owners recorded in the profile JSON. Profile names and paths are not anonymized.
|
||||
active_profile.json: Verbatim copy of the daemon's active profile state file, naming the profile ID that was active and the user it was activated for.
|
||||
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.
|
||||
service_params.json: Sanitized service install parameters (service.json). Sensitive environment variable values are masked. Only present when service.json exists.
|
||||
@@ -171,6 +173,9 @@ The interfaces.txt file contains information about network interfaces, including
|
||||
|
||||
The IP addresses in the interfaces file are anonymized using the same process as described above. Interface names, indexes, MTUs, and flags are not anonymized.
|
||||
|
||||
Profiles
|
||||
The profiles.txt file lists every profile JSON on disk, including profiles the running client does not load. Owners are recorded as principals, "uid:<id>" on Unix and "sid:<sid>" on Windows. The whole owners list is reported, while the client currently honors only the first entry. An empty list means the profile predates ownership or was never claimed.
|
||||
|
||||
Configuration
|
||||
The config.txt file contains anonymized configuration information of the NetBird client. Sensitive information such as private keys and SSH keys are excluded. The following fields are anonymized:
|
||||
- ManagementURL
|
||||
@@ -423,6 +428,10 @@ func (g *BundleGenerator) createArchive() error {
|
||||
log.Errorf("failed to add config to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addProfiles(); err != nil {
|
||||
log.Errorf("failed to add profiles to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addResolvedDomains(); err != nil {
|
||||
log.Errorf("failed to add resolved domains to debug bundle: %v", err)
|
||||
}
|
||||
@@ -432,7 +441,7 @@ func (g *BundleGenerator) createArchive() error {
|
||||
}
|
||||
|
||||
if err := g.addProf(); err != nil {
|
||||
log.Errorf("failed to add profiles to debug bundle: %v", err)
|
||||
log.Errorf("failed to add pprof profiles to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addCPUProfile(); err != nil {
|
||||
@@ -693,6 +702,225 @@ func isSensitiveEnvVar(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
profilesBundleFile = "profiles.txt"
|
||||
activeProfileBundleFile = "active_profile.json"
|
||||
|
||||
profileJSONSuffix = ".json"
|
||||
profileStateJSONSuffix = ".state.json"
|
||||
|
||||
noneValue = "(none)"
|
||||
)
|
||||
|
||||
// profileMeta is the slice of a profile JSON the bundle reports on. The owners
|
||||
// list is read whole, unlike the client, which honors only the first entry.
|
||||
type profileMeta struct {
|
||||
Name string
|
||||
Owners []string
|
||||
}
|
||||
|
||||
// profileEntry is one profile JSON found on disk.
|
||||
type profileEntry struct {
|
||||
id string
|
||||
name string
|
||||
path string
|
||||
owners []string
|
||||
isActive bool
|
||||
// loadErr is kept instead of returned so one unreadable profile does not
|
||||
// hide the rest.
|
||||
loadErr error
|
||||
}
|
||||
|
||||
// addProfiles inventories every profile JSON on disk with its ID, name and
|
||||
// owners, and dumps the active profile state file verbatim.
|
||||
func (g *BundleGenerator) addProfiles() error {
|
||||
activeState, activeRaw, activeErr := readActiveProfileState()
|
||||
if activeErr != nil {
|
||||
log.Warnf("failed to read active profile state for debug bundle: %v", activeErr)
|
||||
}
|
||||
|
||||
entries := collectProfileEntries(activeState)
|
||||
content := renderProfiles(entries, activeState, activeErr)
|
||||
|
||||
if err := g.addFileToZip(strings.NewReader(content), profilesBundleFile); err != nil {
|
||||
return fmt.Errorf("add profiles file to zip: %w", err)
|
||||
}
|
||||
|
||||
if len(activeRaw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(activeRaw), activeProfileBundleFile); err != nil {
|
||||
return fmt.Errorf("add active profile state to zip: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readActiveProfileState reads the state file directly rather than through
|
||||
// ServiceManager, whose getters seed a default one when it is missing. Bundle
|
||||
// collection must not write the state it reports on. The raw bytes come back
|
||||
// even when parsing fails, so a corrupted file still reaches the bundle.
|
||||
func readActiveProfileState() (*profilemanager.ActiveProfileState, []byte, error) {
|
||||
data, err := os.ReadFile(profilemanager.ActiveProfileStatePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("read active profile state: %w", err)
|
||||
}
|
||||
|
||||
var state profilemanager.ActiveProfileState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, data, fmt.Errorf("parse active profile state: %w", err)
|
||||
}
|
||||
|
||||
return &state, data, nil
|
||||
}
|
||||
|
||||
// collectProfileEntries walks the state directory. The default profile sits at
|
||||
// the top level and every subdirectory holds profiles of its own.
|
||||
func collectProfileEntries(active *profilemanager.ActiveProfileState) []profileEntry {
|
||||
root := profilemanager.DefaultConfigPathDir
|
||||
|
||||
var entries []profileEntry
|
||||
if _, err := os.Stat(profilemanager.DefaultConfigPath); err == nil {
|
||||
entries = append(entries, newProfileEntry(profilemanager.DefaultProfileName, profilemanager.DefaultConfigPath, active))
|
||||
}
|
||||
|
||||
dirs, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
log.Warnf("failed to read profiles directory %s: %v", root, err)
|
||||
return entries
|
||||
}
|
||||
|
||||
var nested []profileEntry
|
||||
for _, dir := range dirs {
|
||||
if !dir.IsDir() {
|
||||
continue
|
||||
}
|
||||
nested = append(nested, collectProfilesInDir(filepath.Join(root, dir.Name()), active)...)
|
||||
}
|
||||
|
||||
sort.Slice(nested, func(i, j int) bool { return nested[i].path < nested[j].path })
|
||||
|
||||
return append(entries, nested...)
|
||||
}
|
||||
|
||||
func collectProfilesInDir(dir string, active *profilemanager.ActiveProfileState) []profileEntry {
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Warnf("failed to read profiles directory %s: %v", dir, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []profileEntry
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !isProfileJSON(file.Name()) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, file.Name())
|
||||
id := strings.TrimSuffix(file.Name(), profileJSONSuffix)
|
||||
entries = append(entries, newProfileEntry(id, path, active))
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func isProfileJSON(name string) bool {
|
||||
return strings.HasSuffix(name, profileJSONSuffix) && !strings.HasSuffix(name, profileStateJSONSuffix)
|
||||
}
|
||||
|
||||
func newProfileEntry(id, path string, active *profilemanager.ActiveProfileState) profileEntry {
|
||||
entry := profileEntry{
|
||||
id: id,
|
||||
name: id,
|
||||
path: path,
|
||||
isActive: active != nil && active.ID.String() == id,
|
||||
}
|
||||
|
||||
meta, err := readProfileMeta(path)
|
||||
if err != nil {
|
||||
entry.loadErr = err
|
||||
return entry
|
||||
}
|
||||
|
||||
// The profile loader falls back to the ID when the name field is unset.
|
||||
if name := profilemanager.StripCtrlChars(meta.Name); name != "" {
|
||||
entry.name = name
|
||||
}
|
||||
for _, owner := range meta.Owners {
|
||||
entry.owners = append(entry.owners, profilemanager.StripCtrlChars(owner))
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func readProfileMeta(path string) (profileMeta, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return profileMeta{}, fmt.Errorf("read profile: %w", err)
|
||||
}
|
||||
|
||||
var meta profileMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return profileMeta{}, fmt.Errorf("parse profile: %w", err)
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func renderProfiles(entries []profileEntry, active *profilemanager.ActiveProfileState, activeErr error) string {
|
||||
var content strings.Builder
|
||||
|
||||
content.WriteString("NetBird profiles\n\n")
|
||||
content.WriteString(fmt.Sprintf("Profiles directory: %s\n", profilemanager.DefaultConfigPathDir))
|
||||
content.WriteString(fmt.Sprintf("Active profile state file: %s\n", profilemanager.ActiveProfileStatePath))
|
||||
|
||||
switch {
|
||||
case activeErr != nil:
|
||||
content.WriteString(fmt.Sprintf("Active profile: unknown (%v)\n", activeErr))
|
||||
case active == nil:
|
||||
content.WriteString("Active profile: none recorded\n")
|
||||
default:
|
||||
content.WriteString(fmt.Sprintf("Active profile: %s\n", active.ID))
|
||||
}
|
||||
|
||||
content.WriteString(fmt.Sprintf("Profiles found: %d\n", len(entries)))
|
||||
|
||||
for _, entry := range entries {
|
||||
content.WriteString("\n")
|
||||
entry.render(&content)
|
||||
}
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
func (e profileEntry) render(content *strings.Builder) {
|
||||
content.WriteString(fmt.Sprintf("[%s]\n", e.id))
|
||||
content.WriteString(fmt.Sprintf(" Name: %s\n", e.name))
|
||||
content.WriteString(fmt.Sprintf(" Path: %s\n", e.path))
|
||||
content.WriteString(fmt.Sprintf(" Active: %s\n", yesNo(e.isActive)))
|
||||
content.WriteString(fmt.Sprintf(" Owners: %s\n", valueOrNone(strings.Join(e.owners, ", "))))
|
||||
|
||||
if e.loadErr != nil {
|
||||
content.WriteString(fmt.Sprintf(" Error: %v\n", e.loadErr))
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrNone(value string) string {
|
||||
if value == "" {
|
||||
return noneValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func yesNo(value bool) string {
|
||||
if value {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) {
|
||||
configContent.WriteString("NetBird Client Configuration:\n\n")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -970,3 +971,159 @@ func renderAddConfigSpecific(g *BundleGenerator) string {
|
||||
func newAnonymizerForTest() *anonymize.Anonymizer {
|
||||
return anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
}
|
||||
|
||||
// writeProfileJSON writes a profile config with the given display name and
|
||||
// owner principals, mirroring what the profile manager persists.
|
||||
func writeProfileJSON(t *testing.T, path, name string, owners []string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
|
||||
data, err := json.Marshal(map[string]any{"Name": name, "Owners": owners})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o600))
|
||||
}
|
||||
|
||||
// setupProfilesDir points the profile manager at a temporary state directory
|
||||
// laid out the way the daemon writes it, with the default profile at the top
|
||||
// level and further profiles in subdirectories.
|
||||
func setupProfilesDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
origDir := profilemanager.DefaultConfigPathDir
|
||||
origDefault := profilemanager.DefaultConfigPath
|
||||
origActive := profilemanager.ActiveProfileStatePath
|
||||
t.Cleanup(func() {
|
||||
profilemanager.DefaultConfigPathDir = origDir
|
||||
profilemanager.DefaultConfigPath = origDefault
|
||||
profilemanager.ActiveProfileStatePath = origActive
|
||||
})
|
||||
|
||||
profilemanager.DefaultConfigPathDir = dir
|
||||
profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json")
|
||||
profilemanager.ActiveProfileStatePath = filepath.Join(dir, "active_profile.json")
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func bundleFiles(t *testing.T, add func(g *BundleGenerator) error) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
g := &BundleGenerator{
|
||||
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
|
||||
archive: zw,
|
||||
}
|
||||
require.NoError(t, add(g))
|
||||
require.NoError(t, zw.Close())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
require.NoError(t, err)
|
||||
|
||||
files := make(map[string]string, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
rc, err := f.Open()
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(rc)
|
||||
require.NoError(t, rc.Close())
|
||||
require.NoError(t, err)
|
||||
files[f.Name] = string(content)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func TestAddProfiles(t *testing.T) {
|
||||
t.Run("collects id, name and owners per profile", func(t *testing.T) {
|
||||
dir := setupProfilesDir(t)
|
||||
|
||||
writeProfileJSON(t, profilemanager.DefaultConfigPath, "default", nil)
|
||||
writeProfileJSON(t, filepath.Join(dir, "alice", "aaaa1111.json"), "work", []string{"uid:1000"})
|
||||
writeProfileJSON(t, filepath.Join(dir, "bob", "bbbb2222.json"), "home", []string{"uid:1001"})
|
||||
// State files sit next to the profiles and must not be listed as one.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "alice", "aaaa1111.state.json"), []byte(`{"email":"a@b.c"}`), 0o600))
|
||||
|
||||
active, err := json.Marshal(map[string]string{"name": "aaaa1111", "username": "alice"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(profilemanager.ActiveProfileStatePath, active, 0o600))
|
||||
|
||||
files := bundleFiles(t, (*BundleGenerator).addProfiles)
|
||||
|
||||
profiles := files[profilesBundleFile]
|
||||
require.NotEmpty(t, profiles, "bundle should contain %s", profilesBundleFile)
|
||||
|
||||
assert.Contains(t, profiles, "Profiles found: 3", "default plus both subdirectory profiles should be listed")
|
||||
assert.Contains(t, profiles, "[aaaa1111]")
|
||||
assert.Contains(t, profiles, "Name: work")
|
||||
assert.Contains(t, profiles, "Owners: uid:1000")
|
||||
assert.Contains(t, profiles, "[bbbb2222]")
|
||||
assert.Contains(t, profiles, "Owners: uid:1001")
|
||||
assert.NotContains(t, profiles, "aaaa1111.state.json", "state files are not profiles")
|
||||
|
||||
// The default profile carries no owners yet.
|
||||
assert.Contains(t, profiles, "Owners: (none)")
|
||||
|
||||
assert.Equal(t, string(active), files[activeProfileBundleFile], "active profile state should be dumped verbatim")
|
||||
})
|
||||
|
||||
t.Run("reports every owner, not only the honored one", func(t *testing.T) {
|
||||
dir := setupProfilesDir(t)
|
||||
// The client honors the first owner only, so extra entries are invisible
|
||||
// to it and worth surfacing in the bundle.
|
||||
writeProfileJSON(t, filepath.Join(dir, "alice", "aaaa1111.json"), "work", []string{"uid:1000", "uid:1001", "bogus"})
|
||||
|
||||
entries := collectProfileEntries(nil)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, []string{"uid:1000", "uid:1001", "bogus"}, entries[0].owners, "the whole owners list should be reported")
|
||||
})
|
||||
|
||||
t.Run("marks the active profile in any subdirectory", func(t *testing.T) {
|
||||
dir := setupProfilesDir(t)
|
||||
|
||||
writeProfileJSON(t, filepath.Join(dir, "some-dir", "aaaa1111.json"), "work", []string{"uid:1000"})
|
||||
writeProfileJSON(t, filepath.Join(dir, "other-dir", "bbbb2222.json"), "home", []string{"uid:1001"})
|
||||
|
||||
active, err := json.Marshal(map[string]string{"name": "aaaa1111", "username": "nonexistent"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(profilemanager.ActiveProfileStatePath, active, 0o600))
|
||||
|
||||
entries := collectProfileEntries(mustActiveState(t))
|
||||
require.Len(t, entries, 2)
|
||||
|
||||
for _, entry := range entries {
|
||||
assert.Equal(t, entry.id == "aaaa1111", entry.isActive, "active state should match on ID alone, got %+v", entry)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing active profile state still lists profiles", func(t *testing.T) {
|
||||
dir := setupProfilesDir(t)
|
||||
writeProfileJSON(t, filepath.Join(dir, "alice", "aaaa1111.json"), "work", []string{"uid:1000"})
|
||||
|
||||
files := bundleFiles(t, (*BundleGenerator).addProfiles)
|
||||
|
||||
assert.Contains(t, files[profilesBundleFile], "[aaaa1111]")
|
||||
assert.Contains(t, files[profilesBundleFile], "Active profile: none recorded")
|
||||
assert.NotContains(t, files, activeProfileBundleFile, "no state file means nothing to dump")
|
||||
})
|
||||
|
||||
t.Run("records a parse error and keeps the other profiles", func(t *testing.T) {
|
||||
dir := setupProfilesDir(t)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "alice"), 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "alice", "aaaa1111.json"), []byte("{broken"), 0o600))
|
||||
writeProfileJSON(t, filepath.Join(dir, "alice", "bbbb2222.json"), "work", []string{"uid:1000"})
|
||||
|
||||
entries := collectProfileEntries(nil)
|
||||
require.Len(t, entries, 2)
|
||||
assert.Error(t, entries[0].loadErr, "the unreadable profile should carry its error")
|
||||
assert.Equal(t, "aaaa1111", entries[0].name, "an unreadable profile falls back to its ID as the name")
|
||||
assert.NoError(t, entries[1].loadErr)
|
||||
assert.Equal(t, "work", entries[1].name)
|
||||
})
|
||||
}
|
||||
|
||||
func mustActiveState(t *testing.T) *profilemanager.ActiveProfileState {
|
||||
t.Helper()
|
||||
state, _, err := readActiveProfileState()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, state)
|
||||
return state
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user