diff --git a/client/android/client.go b/client/android/client.go index e47a1c13d..b870337d1 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -213,6 +213,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetEvents(c.netMgr)) c.setState(cfg, cacheDir, cfgFile, connectClient) + connectClient.SetSyncResponsePersistence(true) // This path runs the interactive SSO flow, so reaching here means the peer // is authenticated again — release the latch Status() reports from. Clear // only once the fresh connect client is installed: until then Status() @@ -256,6 +257,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetEvents(c.netMgr)) c.setState(cfg, cacheDir, cfgFile, connectClient) + connectClient.SetSyncResponsePersistence(true) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -327,6 +329,19 @@ func (c *Client) NotifyNetworkChange() { // 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) { + return c.debugBundle(platformFiles, anonymize, anonymizeLevel, true) +} + +// DebugBundleFile generates a debug bundle and returns the path of the zip in +// the cache directory instead of uploading it, so the app can hand the file to +// the user for inspection. The caller owns the file and removes it once done; +// the stale-bundle cleanup of later runs removes it only after a day. +// anonymize and anonymizeLevel behave as in DebugBundle. +func (c *Client) DebugBundleFile(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) { + return c.debugBundle(platformFiles, anonymize, anonymizeLevel, false) +} + +func (c *Client) debugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string, upload bool) (string, error) { cfg, cacheDir, cc := c.stateSnapshot() // If the engine hasn't been started, load config from disk @@ -342,6 +357,11 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym cacheDir = platformFiles.CacheDir() } + // Clear what an interrupted earlier run may have left in the cache before + // adding to it. Remote debug jobs write to the same directory, so anything + // younger than an hour is treated as possibly still in use. + debug.RemoveStaleBundles(cacheDir, time.Hour) + deps := debug.GeneratorDependencies{ InternalConfig: cfg, StatusRecorder: c.recorder, @@ -379,6 +399,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym if err != nil { return "", fmt.Errorf("generate debug bundle: %w", err) } + if !upload { + return debug.ExportBundle(path) + } defer func() { if err := os.Remove(path); err != nil { log.Errorf("failed to remove debug bundle file: %v", err) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index b362ae293..f4d1c3598 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -379,9 +379,38 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen } } +// bundleFilePattern names the bundle zips Generate creates in tempDir; the +// asterisk is filled in by os.CreateTemp. +const bundleFilePattern = "netbird.debug.*.zip" + +const exportedBundlePrefix = "netbird.debug-file." + +const exportedBundleMaxAge = 24 * time.Hour + +// RemoveStaleBundles deletes bundle zips that an interrupted generation or +// upload left behind in dir. Only files older than maxAge go, so a bundle that +// another caller is still writing or uploading in the same directory survives. +// Exported bundles are kept for exportedBundleMaxAge instead. +func RemoveStaleBundles(dir string, maxAge time.Duration) { + removeStaleFiles(dir, bundleFilePattern, maxAge) + removeStaleFiles(dir, exportedBundlePrefix+"*.zip", exportedBundleMaxAge) +} + +// ExportBundle renames a generated bundle out of the RemoveStaleBundles pattern +// and returns the new path. The caller owns the file from then on; an export +// abandoned for longer than exportedBundleMaxAge is removed by RemoveStaleBundles. +func ExportBundle(path string) (string, error) { + base := strings.TrimPrefix(filepath.Base(path), strings.SplitN(bundleFilePattern, "*", 2)[0]) + exported := filepath.Join(filepath.Dir(path), exportedBundlePrefix+base) + if err := os.Rename(path, exported); err != nil { + return "", fmt.Errorf("export debug bundle: %w", err) + } + return exported, nil +} + // Generate creates a debug bundle and returns the location. func (g *BundleGenerator) Generate() (resp string, err error) { - bundlePath, err := os.CreateTemp(g.tempDir, "netbird.debug.*.zip") + bundlePath, err := os.CreateTemp(g.tempDir, bundleFilePattern) if err != nil { return "", fmt.Errorf("create zip file: %w", err) } @@ -1725,3 +1754,26 @@ func anonymizeSlice(v []any, anonymizer *anonymize.Anonymizer) []any { } return v } + +func removeStaleFiles(dir, pattern string, maxAge time.Duration) { + matches, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil { + log.Debugf("glob stale debug bundles in %s: %v", dir, err) + return + } + + cutoff := time.Now().Add(-maxAge) + for _, path := range matches { + info, err := os.Stat(path) + if err != nil || info.ModTime().After(cutoff) { + continue + } + if err := os.Remove(path); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + log.Warnf("remove stale debug bundle %s: %v", path, err) + } + continue + } + log.Infof("removed stale debug bundle %s", path) + } +} diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 17d520358..6a810bccc 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" "net" "net/netip" "net/url" @@ -969,3 +970,52 @@ func renderAddConfigSpecific(g *BundleGenerator) string { func newAnonymizerForTest() *anonymize.Anonymizer { return anonymize.NewAnonymizer(anonymize.DefaultAddresses()) } + +func TestRemoveStaleBundles(t *testing.T) { + dir := t.TempDir() + stale := filepath.Join(dir, "netbird.debug.111.zip") + fresh := filepath.Join(dir, "netbird.debug.222.zip") + other := filepath.Join(dir, "netbird.debug.333.txt") + owned := filepath.Join(dir, "netbird.debug.444.zip") + abandoned := filepath.Join(dir, "netbird.debug.555.zip") + for _, p := range []string{stale, fresh, other, owned, abandoned} { + require.NoError(t, os.WriteFile(p, []byte("x"), 0o600)) + } + exported, err := ExportBundle(owned) + require.NoError(t, err) + exportedAbandoned, err := ExportBundle(abandoned) + require.NoError(t, err) + old := time.Now().Add(-2 * time.Hour) + for _, p := range []string{stale, other, exported} { + require.NoError(t, os.Chtimes(p, old, old)) + } + ancient := time.Now().Add(-exportedBundleMaxAge - time.Hour) + require.NoError(t, os.Chtimes(exportedAbandoned, ancient, ancient)) + + RemoveStaleBundles(dir, time.Hour) + + assert.NoFileExists(t, stale, "bundle older than maxAge should be removed") + assert.FileExists(t, fresh, "bundle younger than maxAge must survive, it may still be uploading") + assert.FileExists(t, other, "files outside the bundle pattern must not be touched") + assert.NoFileExists(t, owned) + assert.FileExists(t, exported, "exported bundle is caller-owned and must survive maxAge") + assert.NoFileExists(t, exportedAbandoned, "exported bundle older than exportedBundleMaxAge is abandoned") +} + +func TestBundleIncludesNetworkMap(t *testing.T) { + for _, anonymize := range []bool{false, true} { + t.Run(fmt.Sprintf("anonymize=%t", anonymize), func(t *testing.T) { + g := NewBundleGenerator(GeneratorDependencies{ + SyncResponse: &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 1}}, + }, BundleConfig{Anonymize: anonymize}) + + require.Contains(t, bundleEntries(t, g), "network_map.json") + }) + } +} + +func TestBundleOmitsNetworkMapWithoutSyncResponse(t *testing.T) { + g := NewBundleGenerator(GeneratorDependencies{}, BundleConfig{}) + + require.NotContains(t, bundleEntries(t, g), "network_map.json") +} diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 3856cae22..14076c051 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -1493,3 +1493,21 @@ func TestOverlayAddrsFromAllowedIPs(t *testing.T) { }) } } + +func TestEngine_SyncResponsePersistence(t *testing.T) { + e := &Engine{} + + _, err := e.GetLatestSyncResponse() + require.Error(t, err, "persistence is disabled by default") + + e.SetSyncResponsePersistence(true) + e.persistSyncResponse(&mgmtProto.SyncResponse{NetworkMap: &mgmtProto.NetworkMap{Serial: 7}}) + + got, err := e.GetLatestSyncResponse() + require.NoError(t, err) + assert.Equal(t, uint64(7), got.GetNetworkMap().GetSerial()) + + e.SetSyncResponsePersistence(false) + _, err = e.GetLatestSyncResponse() + require.Error(t, err) +}