//go:build !(linux && 386) package main import ( "context" "fmt" "path/filepath" "strconv" "sync" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/widget" log "github.com/sirupsen/logrus" "github.com/skratchdot/open-golang/open" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/proto" nbstatus "github.com/netbirdio/netbird/client/status" uptypes "github.com/netbirdio/netbird/upload-server/types" ) // Initial state for the debug collection type debugInitialState struct { wasDown bool logLevel proto.LogLevel isLevelTrace bool } // Debug collection parameters type debugCollectionParams struct { duration time.Duration anonymize bool systemInfo bool upload bool uploadURL string enablePersistence bool } // UI components for progress tracking type progressUI struct { statusLabel *widget.Label progressBar *widget.ProgressBar uiControls []fyne.Disableable window fyne.Window } func (s *serviceClient) showDebugUI() { w := s.app.NewWindow("NetBird Debug") w.SetOnClosed(s.cancel) w.Resize(fyne.NewSize(600, 500)) w.SetFixedSize(true) anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil) systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil) systemInfoCheck.SetChecked(true) uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil) uploadCheck.SetChecked(true) uploadURLLabel := widget.NewLabel("Debug upload URL:") uploadURL := widget.NewEntry() uploadURL.SetText(uptypes.DefaultBundleURL) uploadURL.SetPlaceHolder("Enter upload URL") uploadURLContainer := container.NewVBox( uploadURLLabel, uploadURL, ) uploadCheck.OnChanged = func(checked bool) { if checked { uploadURLContainer.Show() } else { uploadURLContainer.Hide() } } debugModeContainer := container.NewHBox() runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil) runForDurationCheck.SetChecked(true) forLabel := widget.NewLabel("for") durationInput := widget.NewEntry() durationInput.SetText("1") minutesLabel := widget.NewLabel("minute") durationInput.Validator = func(s string) error { return validateMinute(s, minutesLabel) } noteLabel := widget.NewLabel("Note: NetBird will be brought up and down during collection") runForDurationCheck.OnChanged = func(checked bool) { if checked { forLabel.Show() durationInput.Show() minutesLabel.Show() noteLabel.Show() } else { forLabel.Hide() durationInput.Hide() minutesLabel.Hide() noteLabel.Hide() } } debugModeContainer.Add(runForDurationCheck) debugModeContainer.Add(forLabel) debugModeContainer.Add(durationInput) debugModeContainer.Add(minutesLabel) statusLabel := widget.NewLabel("") statusLabel.Hide() progressBar := widget.NewProgressBar() progressBar.Hide() createButton := widget.NewButton("Create Debug Bundle", nil) // UI controls that should be disabled during debug collection uiControls := []fyne.Disableable{ anonymizeCheck, systemInfoCheck, uploadCheck, uploadURL, runForDurationCheck, durationInput, createButton, } createButton.OnTapped = s.getCreateHandler( statusLabel, progressBar, uploadCheck, uploadURL, anonymizeCheck, systemInfoCheck, runForDurationCheck, durationInput, uiControls, w, ) content := container.NewVBox( widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"), widget.NewLabel(""), anonymizeCheck, systemInfoCheck, uploadCheck, uploadURLContainer, widget.NewLabel(""), debugModeContainer, noteLabel, widget.NewLabel(""), statusLabel, progressBar, createButton, ) paddedContent := container.NewPadded(content) w.SetContent(paddedContent) w.Show() } func validateMinute(s string, minutesLabel *widget.Label) error { if val, err := strconv.Atoi(s); err != nil || val < 1 { return fmt.Errorf("must be a number ≥ 1") } if s == "1" { minutesLabel.SetText("minute") } else { minutesLabel.SetText("minutes") } return nil } // disableUIControls disables the provided UI controls func disableUIControls(controls []fyne.Disableable) { for _, control := range controls { control.Disable() } } // enableUIControls enables the provided UI controls func enableUIControls(controls []fyne.Disableable) { for _, control := range controls { control.Enable() } } func (s *serviceClient) getCreateHandler( statusLabel *widget.Label, progressBar *widget.ProgressBar, uploadCheck *widget.Check, uploadURL *widget.Entry, anonymizeCheck *widget.Check, systemInfoCheck *widget.Check, runForDurationCheck *widget.Check, duration *widget.Entry, uiControls []fyne.Disableable, w fyne.Window, ) func() { return func() { disableUIControls(uiControls) statusLabel.Show() var url string if uploadCheck.Checked { url = uploadURL.Text if url == "" { statusLabel.SetText("Error: Upload URL is required when upload is enabled") enableUIControls(uiControls) return } } params := &debugCollectionParams{ anonymize: anonymizeCheck.Checked, systemInfo: systemInfoCheck.Checked, upload: uploadCheck.Checked, uploadURL: url, enablePersistence: true, } runForDuration := runForDurationCheck.Checked if runForDuration { minutes, err := time.ParseDuration(duration.Text + "m") if err != nil { statusLabel.SetText(fmt.Sprintf("Error: Invalid duration: %v", err)) enableUIControls(uiControls) return } params.duration = minutes statusLabel.SetText(fmt.Sprintf("Running in debug mode for %d minutes...", int(minutes.Minutes()))) progressBar.Show() progressBar.SetValue(0) go s.handleRunForDuration( statusLabel, progressBar, uiControls, w, params, ) return } statusLabel.SetText("Creating debug bundle...") go s.handleDebugCreation( anonymizeCheck.Checked, systemInfoCheck.Checked, uploadCheck.Checked, url, statusLabel, uiControls, w, ) } } func (s *serviceClient) handleRunForDuration( statusLabel *widget.Label, progressBar *widget.ProgressBar, uiControls []fyne.Disableable, w fyne.Window, params *debugCollectionParams, ) { progressUI := &progressUI{ statusLabel: statusLabel, progressBar: progressBar, uiControls: uiControls, window: w, } conn, err := s.getSrvClient(failFastTimeout) if err != nil { handleError(progressUI, fmt.Sprintf("Failed to get client for debug: %v", err)) return } initialState, err := s.getInitialState(conn) if err != nil { handleError(progressUI, err.Error()) return } statusOutput, err := s.collectDebugData(conn, initialState, params, progressUI) if err != nil { handleError(progressUI, err.Error()) return } if err := s.createDebugBundleFromCollection(conn, params, statusOutput, progressUI); err != nil { handleError(progressUI, err.Error()) return } s.restoreServiceState(conn, initialState) progressUI.statusLabel.SetText("Bundle created successfully") } // Get initial state of the service func (s *serviceClient) getInitialState(conn proto.DaemonServiceClient) (*debugInitialState, error) { statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{}) if err != nil { return nil, fmt.Errorf(" get status: %v", err) } logLevelResp, err := conn.GetLogLevel(s.ctx, &proto.GetLogLevelRequest{}) if err != nil { return nil, fmt.Errorf("get log level: %v", err) } wasDown := statusResp.Status != string(internal.StatusConnected) && statusResp.Status != string(internal.StatusConnecting) initialLogLevel := logLevelResp.GetLevel() initialLevelTrace := initialLogLevel >= proto.LogLevel_TRACE return &debugInitialState{ wasDown: wasDown, logLevel: initialLogLevel, isLevelTrace: initialLevelTrace, }, nil } // Handle progress tracking during collection func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time.Duration, progress *progressUI) { progress.progressBar.Show() progress.progressBar.SetValue(0) startTime := time.Now() endTime := startTime.Add(duration) wg.Add(1) go func() { defer wg.Done() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: remaining := time.Until(endTime) if remaining <= 0 { remaining = 0 } elapsed := time.Since(startTime) progressVal := float64(elapsed) / float64(duration) if progressVal > 1.0 { progressVal = 1.0 } progress.progressBar.SetValue(progressVal) progress.statusLabel.SetText(fmt.Sprintf("Running with trace logs... %s remaining", formatDuration(remaining))) } } }() } func (s *serviceClient) configureServiceForDebug( conn proto.DaemonServiceClient, state *debugInitialState, enablePersistence bool, ) error { if state.wasDown { if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { return fmt.Errorf("bring service up: %v", err) } log.Info("Service brought up for debug") time.Sleep(time.Second * 10) } if !state.isLevelTrace { if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel_TRACE}); err != nil { return fmt.Errorf("set log level to TRACE: %v", err) } log.Info("Log level set to TRACE for debug") } if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { return fmt.Errorf("bring service down: %v", err) } time.Sleep(time.Second) if enablePersistence { if _, err := conn.SetNetworkMapPersistence(s.ctx, &proto.SetNetworkMapPersistenceRequest{ Enabled: true, }); err != nil { return fmt.Errorf("enable network map persistence: %v", err) } log.Info("Network map persistence enabled for debug") } if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { return fmt.Errorf("bring service back up: %v", err) } time.Sleep(time.Second * 3) return nil } func (s *serviceClient) collectDebugData( conn proto.DaemonServiceClient, state *debugInitialState, params *debugCollectionParams, progress *progressUI, ) (string, error) { ctx, cancel := context.WithTimeout(s.ctx, params.duration) defer cancel() var wg sync.WaitGroup startProgressTracker(ctx, &wg, params.duration, progress) if err := s.configureServiceForDebug(conn, state, params.enablePersistence); err != nil { return "", err } postUpStatus, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true}) if err != nil { log.Warnf("Failed to get post-up status: %v", err) } var postUpStatusOutput string if postUpStatus != nil { overview := nbstatus.ConvertToStatusOutputOverview(postUpStatus, params.anonymize, "", nil, nil, nil) postUpStatusOutput = nbstatus.ParseToFullDetailSummary(overview) } headerPostUp := fmt.Sprintf("----- NetBird post-up - Timestamp: %s", time.Now().Format(time.RFC3339)) statusOutput := fmt.Sprintf("%s\n%s", headerPostUp, postUpStatusOutput) wg.Wait() progress.progressBar.Hide() progress.statusLabel.SetText("Collecting debug data...") preDownStatus, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true}) if err != nil { log.Warnf("Failed to get pre-down status: %v", err) } var preDownStatusOutput string if preDownStatus != nil { overview := nbstatus.ConvertToStatusOutputOverview(preDownStatus, params.anonymize, "", nil, nil, nil) preDownStatusOutput = nbstatus.ParseToFullDetailSummary(overview) } headerPreDown := fmt.Sprintf("----- NetBird pre-down - Timestamp: %s - Duration: %s", time.Now().Format(time.RFC3339), params.duration) statusOutput = fmt.Sprintf("%s\n%s\n%s", statusOutput, headerPreDown, preDownStatusOutput) return statusOutput, nil } // Create the debug bundle with collected data func (s *serviceClient) createDebugBundleFromCollection( conn proto.DaemonServiceClient, params *debugCollectionParams, statusOutput string, progress *progressUI, ) error { progress.statusLabel.SetText("Creating debug bundle with collected logs...") request := &proto.DebugBundleRequest{ Anonymize: params.anonymize, Status: statusOutput, SystemInfo: params.systemInfo, } if params.upload { request.UploadURL = params.uploadURL } resp, err := conn.DebugBundle(s.ctx, request) if err != nil { return fmt.Errorf("create debug bundle: %v", err) } // Show appropriate dialog based on upload status localPath := resp.GetPath() uploadFailureReason := resp.GetUploadFailureReason() uploadedKey := resp.GetUploadedKey() if params.upload { if uploadFailureReason != "" { showUploadFailedDialog(progress.window, localPath, uploadFailureReason) } else { showUploadSuccessDialog(progress.window, localPath, uploadedKey) } } else { showBundleCreatedDialog(progress.window, localPath) } enableUIControls(progress.uiControls) return nil } // Restore service to original state func (s *serviceClient) restoreServiceState(conn proto.DaemonServiceClient, state *debugInitialState) { if state.wasDown { if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { log.Errorf("Failed to restore down state: %v", err) } else { log.Info("Service state restored to down") } } if !state.isLevelTrace { if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: state.logLevel}); err != nil { log.Errorf("Failed to restore log level: %v", err) } else { log.Info("Log level restored to original setting") } } } // Handle errors during debug collection func handleError(progress *progressUI, errMsg string) { log.Errorf("%s", errMsg) progress.statusLabel.SetText(errMsg) progress.progressBar.Hide() enableUIControls(progress.uiControls) } func (s *serviceClient) handleDebugCreation( anonymize bool, systemInfo bool, upload bool, uploadURL string, statusLabel *widget.Label, uiControls []fyne.Disableable, w fyne.Window, ) { log.Infof("Creating debug bundle (Anonymized: %v, System Info: %v, Upload Attempt: %v)...", anonymize, systemInfo, upload) resp, err := s.createDebugBundle(anonymize, systemInfo, uploadURL) if err != nil { log.Errorf("Failed to create debug bundle: %v", err) statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err)) enableUIControls(uiControls) return } localPath := resp.GetPath() uploadFailureReason := resp.GetUploadFailureReason() uploadedKey := resp.GetUploadedKey() if upload { if uploadFailureReason != "" { showUploadFailedDialog(w, localPath, uploadFailureReason) } else { showUploadSuccessDialog(w, localPath, uploadedKey) } } else { showBundleCreatedDialog(w, localPath) } enableUIControls(uiControls) statusLabel.SetText("Bundle created successfully") } func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploadURL string) (*proto.DebugBundleResponse, error) { conn, err := s.getSrvClient(failFastTimeout) if err != nil { return nil, fmt.Errorf("get client: %v", err) } statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true}) if err != nil { log.Warnf("failed to get status for debug bundle: %v", err) } var statusOutput string if statusResp != nil { overview := nbstatus.ConvertToStatusOutputOverview(statusResp, anonymize, "", nil, nil, nil) statusOutput = nbstatus.ParseToFullDetailSummary(overview) } request := &proto.DebugBundleRequest{ Anonymize: anonymize, Status: statusOutput, SystemInfo: systemInfo, } if uploadURL != "" { request.UploadURL = uploadURL } resp, err := conn.DebugBundle(s.ctx, request) if err != nil { return nil, fmt.Errorf("failed to create debug bundle via daemon: %v", err) } return resp, nil } // formatDuration formats a duration in HH:MM:SS format func formatDuration(d time.Duration) string { d = d.Round(time.Second) h := d / time.Hour d %= time.Hour m := d / time.Minute d %= time.Minute s := d / time.Second return fmt.Sprintf("%02d:%02d:%02d", h, m, s) } // createButtonWithAction creates a button with the given label and action func createButtonWithAction(label string, action func()) *widget.Button { button := widget.NewButton(label, action) return button } // showUploadFailedDialog displays a dialog when upload fails func showUploadFailedDialog(w fyne.Window, localPath, failureReason string) { content := container.NewVBox( widget.NewLabel(fmt.Sprintf("Bundle upload failed:\n%s\n\n"+ "A local copy was saved at:\n%s", failureReason, localPath)), ) customDialog := dialog.NewCustom("Upload Failed", "Cancel", content, w) buttonBox := container.NewHBox( createButtonWithAction("Open file", func() { log.Infof("Attempting to open local file: %s", localPath) if openErr := open.Start(localPath); openErr != nil { log.Errorf("Failed to open local file '%s': %v", localPath, openErr) dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) } }), createButtonWithAction("Open folder", func() { folderPath := filepath.Dir(localPath) log.Infof("Attempting to open local folder: %s", folderPath) if openErr := open.Start(folderPath); openErr != nil { log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) } }), ) content.Add(buttonBox) customDialog.Show() } // showUploadSuccessDialog displays a dialog when upload succeeds func showUploadSuccessDialog(w fyne.Window, localPath, uploadedKey string) { log.Infof("Upload key: %s", uploadedKey) keyEntry := widget.NewEntry() keyEntry.SetText(uploadedKey) keyEntry.Disable() content := container.NewVBox( widget.NewLabel("Bundle uploaded successfully!"), widget.NewLabel(""), widget.NewLabel("Upload key:"), keyEntry, widget.NewLabel(""), widget.NewLabel(fmt.Sprintf("Local copy saved at:\n%s", localPath)), ) customDialog := dialog.NewCustom("Upload Successful", "OK", content, w) copyBtn := createButtonWithAction("Copy key", func() { w.Clipboard().SetContent(uploadedKey) log.Info("Upload key copied to clipboard") }) buttonBox := createButtonBox(localPath, w, copyBtn) content.Add(buttonBox) customDialog.Show() } // showBundleCreatedDialog displays a dialog when bundle is created without upload func showBundleCreatedDialog(w fyne.Window, localPath string) { content := container.NewVBox( widget.NewLabel(fmt.Sprintf("Bundle created locally at:\n%s\n\n"+ "Administrator privileges may be required to access the file.", localPath)), ) customDialog := dialog.NewCustom("Debug Bundle Created", "Cancel", content, w) buttonBox := createButtonBox(localPath, w, nil) content.Add(buttonBox) customDialog.Show() } func createButtonBox(localPath string, w fyne.Window, elems ...fyne.Widget) *fyne.Container { box := container.NewHBox() for _, elem := range elems { box.Add(elem) } fileBtn := createButtonWithAction("Open file", func() { log.Infof("Attempting to open local file: %s", localPath) if openErr := open.Start(localPath); openErr != nil { log.Errorf("Failed to open local file '%s': %v", localPath, openErr) dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) } }) folderBtn := createButtonWithAction("Open folder", func() { folderPath := filepath.Dir(localPath) log.Infof("Attempting to open local folder: %s", folderPath) if openErr := open.Start(folderPath); openErr != nil { log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) } }) box.Add(fileBtn) box.Add(folderBtn) return box }