Skip to content

Commit

Permalink
feat: Add diagnostics package for collecting system and config inform…
Browse files Browse the repository at this point in the history
…ation
  • Loading branch information
tphakala committed Sep 25, 2024
1 parent 0cd5617 commit 7216a49
Show file tree
Hide file tree
Showing 4 changed files with 339 additions and 0 deletions.
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/tphakala/birdnet-go/cmd/license"
"github.com/tphakala/birdnet-go/cmd/rangefilter"
"github.com/tphakala/birdnet-go/cmd/realtime"
"github.com/tphakala/birdnet-go/cmd/support"
"github.com/tphakala/birdnet-go/internal/conf"
)

Expand All @@ -37,6 +38,7 @@ func RootCommand(settings *conf.Settings) *cobra.Command {
authorsCmd := authors.Command()
licenseCmd := license.Command()
rangeCmd := rangefilter.Command(settings)
supportCmd := support.Command(settings)

subcommands := []*cobra.Command{
fileCmd,
Expand All @@ -45,6 +47,7 @@ func RootCommand(settings *conf.Settings) *cobra.Command {
authorsCmd,
licenseCmd,
rangeCmd,
supportCmd,
}

rootCmd.AddCommand(subcommands...)
Expand Down
26 changes: 26 additions & 0 deletions cmd/support/collect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package support

import (
"fmt"
"os"

"github.com/spf13/cobra"
"github.com/tphakala/birdnet-go/internal/diagnostics"
)

// DiagnosticsCommand creates the diagnostics subcommand
func CollectCommand() *cobra.Command {
return &cobra.Command{
Use: "collect",
Short: "Collect system diagnostics for troubleshooting",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Collecting diagnostics...")
zipFile, err := diagnostics.CollectDiagnostics()
if err != nil {
fmt.Printf("Error collecting diagnostics: %v\n", err)
os.Exit(1)
}
fmt.Printf("Diagnostics collected and saved to: %s\n", zipFile)
},
}
}
19 changes: 19 additions & 0 deletions cmd/support/support.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package support

import (
"github.com/spf13/cobra"
"github.com/tphakala/birdnet-go/internal/conf"
)

// Command creates the support parent command
func Command(settings *conf.Settings) *cobra.Command {
supportCmd := &cobra.Command{
Use: "support",
Short: "Commands related to support operations in BirdNET-Go",
}

// Add subcommands here
supportCmd.AddCommand(CollectCommand())

return supportCmd
}
291 changes: 291 additions & 0 deletions internal/diagnostics/diagnostics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
// Package diagnostics provides functions for collecting and reporting diagnostics information
package diagnostics

import (
"archive/zip"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"

"github.com/tphakala/birdnet-go/internal/conf"
)

// CollectDiagnostics gathers system information and logs
func CollectDiagnostics() (string, error) {
tmpDir, err := os.MkdirTemp("", "birdnet-go-diagnostics-")
if err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}

// Collect OS-specific diagnostics
switch runtime.GOOS {
case "linux":
err = collectLinuxDiagnostics(tmpDir)
case "windows":
err = collectWindowsDiagnostics(tmpDir)
case "darwin":
err = collectMacOSDiagnostics(tmpDir)
default:
err = fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}

if err != nil {
return "", err
}

// Compress the diagnostics files
zipFile := tmpDir + ".zip"
err = zipDirectory(tmpDir, zipFile)
if err != nil {
return "", fmt.Errorf("failed to compress diagnostics: %w", err)
}

// Clean up the temporary directory
os.RemoveAll(tmpDir)

return zipFile, nil
}

func collectLinuxDiagnostics(tmpDir string) error {
// Check if system is systemd-based with journald
if hasSystemd() {
collectJournaldLogs(tmpDir)
}

// Collect hardware details
runCommand("lshw", []string{"-short"}, filepath.Join(tmpDir, "hardware_info.txt"))

// Check for Raspberry Pi
if isRaspberryPi() {
runCommand("cat", []string{"/proc/cpuinfo"}, filepath.Join(tmpDir, "raspberry_pi_info.txt"))
}

// Collect package list
collectPackageList(tmpDir)

// Collect sound devices
collectSoundDevices(tmpDir)

// Collect resource information
collectResourceInfo(tmpDir)

// Collect config file
if err := collectConfigFile(tmpDir); err != nil {
fmt.Printf("Warning: Failed to collect config file: %v\n", err)
}

return nil
}

func collectWindowsDiagnostics(tmpDir string) error {
// Implement Windows-specific diagnostics collection
fmt.Println("Not implemented yet")
return nil
}

func collectMacOSDiagnostics(tmpDir string) error {
// Implement macOS-specific diagnostics collection
fmt.Println("Not implemented yet")
return nil
}

func hasSystemd() bool {
_, err := os.Stat("/run/systemd/system")
return err == nil
}

func collectJournaldLogs(tmpDir string) {
sevenDaysAgo := time.Now().AddDate(0, 0, -7).Format("2006-01-02 15:04:05")
runCommand("journalctl", []string{"-u", "birdnet-go", "--since", sevenDaysAgo}, filepath.Join(tmpDir, "birdnet-go_logs.txt"))
}

func isRaspberryPi() bool {
content, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return false
}
return strings.Contains(string(content), "Raspberry Pi")
}

func collectPackageList(tmpDir string) {
if _, err := exec.LookPath("dpkg"); err == nil {
runCommand("dpkg", []string{"-l"}, filepath.Join(tmpDir, "package_list_dpkg.txt"))
} else if _, err := exec.LookPath("rpm"); err == nil {
runCommand("rpm", []string{"-qa"}, filepath.Join(tmpDir, "package_list_rpm.txt"))
} else {
// Fallback to a generic package list method
runCommand("ls", []string{"/var/lib/dpkg/info/*.list"}, filepath.Join(tmpDir, "package_list_generic.txt"))
}
}

func collectSoundDevices(tmpDir string) {
runCommand("aplay", []string{"-l"}, filepath.Join(tmpDir, "alsa_devices.txt"))
runCommand("pactl", []string{"list"}, filepath.Join(tmpDir, "pulseaudio_info.txt"))
runCommand("pw-cli", []string{"list-objects"}, filepath.Join(tmpDir, "pipewire_info.txt"))
runCommand("lsusb", []string{}, filepath.Join(tmpDir, "usb_devices.txt"))
}

func collectResourceInfo(tmpDir string) {
runCommand("free", []string{"-h"}, filepath.Join(tmpDir, "memory_info.txt"))
runCommand("df", []string{"-h"}, filepath.Join(tmpDir, "disk_space.txt"))
runCommand("lsblk", []string{}, filepath.Join(tmpDir, "block_devices.txt"))
runCommand("top", []string{"-bn1"}, filepath.Join(tmpDir, "cpu_info.txt"))
}

func runCommand(command string, args []string, outputFile string) {
cmd := exec.Command(command, args...)
output, _ := cmd.CombinedOutput()
os.WriteFile(outputFile, output, 0644)

Check failure on line 144 in internal/diagnostics/diagnostics.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `os.WriteFile` is not checked (errcheck)
}

func zipDirectory(source, target string) error {
zipfile, err := os.Create(target)
if err != nil {
return err
}
defer zipfile.Close()

archive := zip.NewWriter(zipfile)
defer archive.Close()

filepath.Walk(source, func(path string, info os.FileInfo, err error) error {

Check failure on line 157 in internal/diagnostics/diagnostics.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `filepath.Walk` is not checked (errcheck)
if err != nil {
return err
}

header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}

header.Name = strings.TrimPrefix(path, source+"/")
if info.IsDir() {
header.Name += "/"
} else {
header.Method = zip.Deflate
}

writer, err := archive.CreateHeader(header)
if err != nil {
return err
}

if !info.IsDir() {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open file %s: %w", path, err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
err = fmt.Errorf("failed to close file %s: %w (previous error: %w)", path, closeErr, err)
}
}()

_, err = io.Copy(writer, file)
if err != nil {
return fmt.Errorf("failed to copy file %s to zip: %w", path, err)
}
}
return err
})

return nil
}

func collectConfigFile(tmpDir string) error {
configPaths, err := conf.GetDefaultConfigPaths()
if err != nil {
return fmt.Errorf("error getting default config paths: %w", err)
}

var configPath string
for _, path := range configPaths {
possiblePath := filepath.Join(path, "config.yaml")
if _, err := os.Stat(possiblePath); err == nil {
configPath = possiblePath
break
}
}

if configPath == "" {
return fmt.Errorf("config.yaml not found in any of the default paths")
}

content, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("error reading config file: %w", err)
}

maskedContent := maskSensitiveInfo(string(content))

outputPath := filepath.Join(tmpDir, "config.yaml")
err = os.WriteFile(outputPath, []byte(maskedContent), 0644)
if err != nil {
return fmt.Errorf("error writing masked config file: %w", err)
}

return nil
}

func maskSensitiveInfo(content string) string {
lines := strings.Split(content, "\n")
sensitiveFields := map[string]bool{
"id": true,
"apikey": true,
"username": true,
"password": true,
"broker": true,
"topic": true,
"urls": true,
}

for i, line := range lines {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(strings.ToLower(parts[0]))
value := strings.TrimSpace(parts[1])

if sensitiveFields[key] {
maskedValue := maskValue(value)
lines[i] = fmt.Sprintf("%s: %s", parts[0], maskedValue)
} else if isIPOrURL(value) && !isLocalhost(value) {
maskedValue := maskIPOrURL(value)
lines[i] = fmt.Sprintf("%s: %s", parts[0], maskedValue)
}
}
}

return strings.Join(lines, "\n")
}

func maskValue(value string) string {
length := len(value)
return strings.Repeat("*", length)
}

func isIPOrURL(value string) bool {
ipRegex := regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}(:\d+)?$`)
urlRegex := regexp.MustCompile(`^(http|https|rtsp):\/\/`)
return ipRegex.MatchString(value) || urlRegex.MatchString(value)
}

func isLocalhost(value string) bool {
return value == "127.0.0.1" || value == "0.0.0.0" || strings.HasPrefix(value, "localhost")
}

func maskIPOrURL(value string) string {
parts := strings.Split(value, "://")
if len(parts) > 1 {
protocol := parts[0]
rest := parts[1]
maskedRest := maskValue(rest)
return fmt.Sprintf("%s://%s", protocol, maskedRest)
}
return maskValue(value)
}

0 comments on commit 7216a49

Please sign in to comment.