package main import ( "context" "fmt" "os" "os/signal" "path/filepath" "strings" "sync/atomic" "syscall" "time" "github.com/spf13/cobra" "github.com/netsynth/netsynth/aggregate" "github.com/netsynth/netsynth/capture" "github.com/netsynth/netsynth/classify" "github.com/netsynth/netsynth/encode" ) var ( ifaceName string listIfaces bool verbose bool outputPath string bpfFilter string // NEW: --filter flag (CAPT-05) readPath string // NEW: --read flag (CAPT-06) ) func main() { rootCmd := &cobra.Command{ Use: "netsynth", Short: "Sonify live network traffic into ambient audio", Long: "NetSynth captures network traffic, classifies it by protocol, and synthesizes an ambient MP3 soundscape.", RunE: run, } rootCmd.Flags().StringVarP(&ifaceName, "interface", "i", "", "Network interface to capture on (required unless --list-interfaces)") rootCmd.Flags().BoolVar(&listIfaces, "list-interfaces", false, "List available network interfaces and exit") rootCmd.Flags().BoolVar(&verbose, "verbose", false, "Print per-window protocol activity to stderr") rootCmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output MP3 file path (default: netsynth-.mp3)") rootCmd.Flags().StringVar(&bpfFilter, "filter", "", "BPF filter expression (tcpdump syntax, e.g. \"port 53\")") rootCmd.Flags().StringVar(&readPath, "read", "", "Read packets from pcap file instead of live capture") if err := rootCmd.Execute(); err != nil { os.Exit(1) } } // deriveOutputPath replaces the file extension with .mp3 per D-05. // "capture.pcap" -> "capture.mp3", "noext" -> "noext.mp3" func deriveOutputPath(rPath string) string { ext := filepath.Ext(rPath) if ext == "" { return rPath + ".mp3" } return strings.TrimSuffix(rPath, ext) + ".mp3" } func run(cmd *cobra.Command, args []string) error { // --list-interfaces mode (CAPT-02) if listIfaces { return runListInterfaces() } // D-03: --read and -i are mutually exclusive if readPath != "" && ifaceName != "" { return fmt.Errorf("--read and -i are mutually exclusive; use one or the other") } if readPath == "" && ifaceName == "" { return fmt.Errorf("interface required: use -i , --read , or --list-interfaces to see available interfaces") } // Pre-validate BPF filter before any capture (CAPT-05, Pitfall 1) if bpfFilter != "" { if err := capture.ValidateBPFFilter(bpfFilter); err != nil { return err } } // Resolve output path if outputPath == "" { if readPath != "" { outputPath = deriveOutputPath(readPath) // D-05 } else { outputPath = fmt.Sprintf("netsynth-%s.mp3", time.Now().Format("20060102-150405")) } } if readPath != "" { return runPcapMode(cmd) } return runLiveMode(cmd) } // runLiveMode runs the live packet capture pipeline. func runLiveMode(cmd *cobra.Command) error { // Set up signal handling (Ctrl+C) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // Stage 1: Capture (CAPT-01) with optional BPF filter (CAPT-05) fmt.Fprintf(os.Stderr, "Starting capture on %s... (press Ctrl+C to stop)\n", ifaceName) packets, droppedPtr, err := capture.StartCapture(ctx, ifaceName, bpfFilter) if err != nil { return err // CAPT-04: permission error already has platform-specific message } // Stage 2: Classify (CLAS-01) classifier := classify.NewClassifier(classify.DefaultRules) classified := make(chan classify.ClassifiedPacket, 1024) go func() { defer close(classified) for pkt := range packets { classified <- classifier.Classify(pkt) } }() // Stage 3: Aggregate with optional verbose callback (CLAS-04) var onSnapshot func(classify.WindowSnapshot) if verbose { onSnapshot = func(snap classify.WindowSnapshot) { aggregate.PrintWindowLine(os.Stderr, snap) // CLAS-04: --verbose output } } snapshots := aggregate.Aggregate(ctx.Done(), classified, aggregate.DefaultWindowMs, onSnapshot) // Accumulate snapshots for synthesis totals := make(map[classify.TrafficClass]int64) var collectedSnapshots []classify.WindowSnapshot for snap := range snapshots { collectedSnapshots = append(collectedSnapshots, snap) aggregate.AccumulateTotals(totals, snap) } // D-08: Print protocol summary BEFORE encoding — user sees stats immediately dropped := atomic.LoadInt64(droppedPtr) if dropped > 0 { fmt.Fprintf(os.Stderr, "\nWarning: %d packets dropped (channel buffer full)\n", dropped) } aggregate.PrintSummary(os.Stderr, totals) // D-07: Encoding status line fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n", len(collectedSnapshots), outputPath) encodeStart := time.Now() if err := encode.RunSynthesis(collectedSnapshots, outputPath); err != nil { return fmt.Errorf("synthesis failed: %w", err) } encodeElapsed := time.Since(encodeStart) // D-09: Saved confirmation with path, audio duration, file size, encoding time audioDuration := float64(len(collectedSnapshots)) * float64(aggregate.DefaultWindowMs) / 1000.0 info, statErr := os.Stat(outputPath) if statErr != nil { return fmt.Errorf("stat output file: %w", statErr) } fmt.Fprintf(os.Stderr, "Saved %s (%.1fs, %d KB, encoded in %.1fs)\n", outputPath, audioDuration, info.Size()/1024, encodeElapsed.Seconds()) return nil } // runPcapMode runs the pcap file processing pipeline (CAPT-06). func runPcapMode(cmd *cobra.Command) error { // D-06: bookend start message fmt.Fprintf(os.Stderr, "Reading %s...\n", readPath) // Open pcap file with optional software BPF filter (D-04) packets, err := capture.ReadPcapFile(readPath, bpfFilter) if err != nil { return err } // Classify packets (reuse same classifier) classifier := classify.NewClassifier(classify.DefaultRules) classified := make(chan classify.ClassifiedPacket, 1024) go func() { defer close(classified) for pkt := range packets { cp := classifier.Classify(pkt) // Set timestamp from pcap metadata for D-01 window assignment cp.Timestamp = pkt.Metadata().CaptureInfo.Timestamp classified <- cp } }() // D-07: --verbose callback (same as live mode) var onSnapshot func(classify.WindowSnapshot) if verbose { onSnapshot = func(snap classify.WindowSnapshot) { aggregate.PrintWindowLine(os.Stderr, snap) } } // Timestamp-based aggregation (D-01, D-02) collectedSnapshots := aggregate.AggregatePcap(classified, aggregate.DefaultWindowMs, onSnapshot) // Accumulate totals for summary totals := make(map[classify.TrafficClass]int64) for _, snap := range collectedSnapshots { aggregate.AccumulateTotals(totals, snap) } // D-06: protocol summary (same format as live mode) aggregate.PrintSummary(os.Stderr, totals) // Check for empty pcap (Pitfall 4: better error than generic "no packets captured") if len(collectedSnapshots) == 0 { return fmt.Errorf("pcap file %q contains no packets (after filtering)", readPath) } // Encode fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n", len(collectedSnapshots), outputPath) encodeStart := time.Now() if err := encode.RunSynthesis(collectedSnapshots, outputPath); err != nil { return fmt.Errorf("synthesis failed: %w", err) } encodeElapsed := time.Since(encodeStart) // D-06: bookend end message audioDuration := float64(len(collectedSnapshots)) * float64(aggregate.DefaultWindowMs) / 1000.0 info, statErr := os.Stat(outputPath) if statErr != nil { return fmt.Errorf("stat output file: %w", statErr) } fmt.Fprintf(os.Stderr, "Saved %s (%.1fs, %d KB, encoded in %.1fs)\n", outputPath, audioDuration, info.Size()/1024, encodeElapsed.Seconds()) return nil } func runListInterfaces() error { ifaces, err := capture.ListInterfaces() if err != nil { return fmt.Errorf("error listing interfaces: %w", err) } if len(ifaces) == 0 { fmt.Fprintln(os.Stderr, "No interfaces found. If interfaces are missing, run with sudo.") return nil } fmt.Fprintln(os.Stderr, "Available interfaces:") for _, iface := range ifaces { addrs, _ := iface.Addrs() addrStrs := make([]string, len(addrs)) for i, a := range addrs { addrStrs[i] = a.String() } fmt.Fprintf(os.Stderr, " %-15s flags=%s addrs=%s\n", iface.Name, iface.Flags, strings.Join(addrStrs, ", ")) } return nil }