Files
yoloyolo/.planning/phases/04-power-user-features/04-02-PLAN.md
T
2026-03-26 14:25:52 +01:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-power-user-features 02 execute 2
04-01
cmd/netsynth/main.go
cmd/netsynth/main_test.go
true
CAPT-05
CAPT-06
truths artifacts key_links
User can run netsynth -i eth0 --filter 'port 53' and only matching traffic is captured
User can run netsynth --read capture.pcap -o out.mp3 and receive a valid MP3
An invalid BPF filter produces a clear error before any capture begins
--read and -i are mutually exclusive with a clear error message
--read without -o derives output filename from input pcap (capture.pcap -> capture.mp3)
--read displays bookend messages: 'Reading <file>...' at start, summary + 'Saved' at end
--verbose works with --read showing per-window activity
path provides contains
cmd/netsynth/main.go CLI wiring for --filter and --read flags with branching run logic
--filter
--read
ReadPcapFile
AggregatePcap
deriveOutputPath
from to via pattern
cmd/netsynth/main.go capture/bpf.go ValidateBPFFilter call before capture capture.ValidateBPFFilter
from to via pattern
cmd/netsynth/main.go capture/pcap_reader.go ReadPcapFile for --read mode capture.ReadPcapFile
from to via pattern
cmd/netsynth/main.go aggregate/window.go AggregatePcap for pcap mode aggregate.AggregatePcap
Wire --filter and --read flags into the CLI, branching run() into live and pcap paths.

Purpose: Complete the user-facing features CAPT-05 and CAPT-06 by connecting the library functions from Plan 01 into the Cobra CLI. Output: Updated cmd/netsynth/main.go with both flags, mutual exclusion, filename derivation, bookend messages, and pcap processing path. Tests for flag interactions.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-power-user-features/04-CONTEXT.md @.planning/phases/04-power-user-features/04-RESEARCH.md @.planning/phases/04-power-user-features/04-01-SUMMARY.md

@cmd/netsynth/main.go @cmd/netsynth/main_test.go

From capture/bpf.go (created in Plan 01):

func ValidateBPFFilter(expr string) error
func CompileSoftwareBPF(expr string) (*bpf.VM, error)

From capture/pcap_reader.go (created in Plan 01):

func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error)

From capture/capture.go (updated in Plan 01):

func OpenCapture(ctx context.Context, iface string, filter string) (*pcap.Handle, error)
func StartCapture(ctx context.Context, iface string, filter string) (<-chan gopacket.Packet, *int64, error)

From aggregate/window.go (updated in Plan 01):

func AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) []classify.WindowSnapshot

From classify/types.go (updated in Plan 01):

type ClassifiedPacket struct {
    Class     TrafficClass
    SrcPort   uint16
    DstPort   uint16
    Protocol  string
    Length    int
    Timestamp time.Time
}

Existing from cmd/netsynth/main.go:

var (
    ifaceName  string
    listIfaces bool
    verbose    bool
    outputPath string
)
func run(cmd *cobra.Command, args []string) error
Task 1: Add --filter and --read flags with branching run logic cmd/netsynth/main.go, cmd/netsynth/main_test.go cmd/netsynth/main.go, cmd/netsynth/main_test.go, capture/bpf.go, capture/pcap_reader.go, aggregate/window.go - TestFlagMutualExclusion: Running with both --read and -i returns error containing "mutually exclusive" - TestMissingSource: Running without --read and without -i returns error containing "interface required" and mentions "--read" - TestDeriveOutputPath: deriveOutputPath("capture.pcap") returns "capture.mp3"; deriveOutputPath("/tmp/net.pcap") returns "/tmp/net.mp3"; deriveOutputPath("noext") returns "noext.mp3" - TestFilterFlagRegistered: The root command has a --filter flag registered - TestReadFlagRegistered: The root command has a --read flag registered - TestInvalidBPFFilter: Running with -i lo --filter "invalid garbage xyz" returns error containing "invalid BPF filter" (no capture started) - TestHelpOutputNewFlags: --help output contains "--filter" and "--read" **1. Add new global variables and flag registration in main():** ```go var ( ifaceName string listIfaces bool verbose bool outputPath string bpfFilter string // NEW: --filter flag readPath string // NEW: --read flag ) ``` In `main()`, add after existing flag registrations: ```go 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") ```
**2. Add deriveOutputPath helper function:**
```go
// deriveOutputPath replaces the file extension with .mp3 per D-05.
// "capture.pcap" -> "capture.mp3", "noext" -> "noext.mp3"
func deriveOutputPath(readPath string) string {
    ext := filepath.Ext(readPath)
    if ext == "" {
        return readPath + ".mp3"
    }
    return strings.TrimSuffix(readPath, ext) + ".mp3"
}
```
Add `"path/filepath"` to imports.

**3. Rewrite run() with branching logic:**

At the top of `run()`, after the `--list-interfaces` check:

```go
// 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 <interface>, --read <file>, or --list-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"))
    }
}
```

Then branch into live vs pcap mode:

```go
if readPath != "" {
    return runPcapMode(cmd)
}
return runLiveMode(cmd)
```

**4. Extract existing live capture logic into runLiveMode(cmd):**
Move the existing pipeline code (signal handling, StartCapture, classify goroutine, Aggregate, snapshot collection, summary, encoding, saved message) into `func runLiveMode(cmd *cobra.Command) error`. Pass `bpfFilter` to `capture.StartCapture(ctx, ifaceName, bpfFilter)`.

**5. Create runPcapMode(cmd):**
```go
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
}
```

**6. Update tests in cmd/netsynth/main_test.go:**
Add new test functions per behavior list. Update `newTestCmd()` helper to include the new `--filter` and `--read` flags wired to the global `bpfFilter` and `readPath` variables. Tests for mutual exclusion and deriveOutputPath are pure logic tests (no privileges needed). The invalid BPF filter test uses `-i lo --filter "invalid garbage xyz"` — the BPF validation runs before capture starts, so it returns error without needing capture privileges.

Update the existing `TestMissingInterfaceFlag` test to also verify the error message now mentions `--read`.
cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./cmd/netsynth/... -run "TestFlagMutualExclusion|TestMissingSource|TestDeriveOutputPath|TestFilterFlag|TestReadFlag|TestInvalidBPF|TestHelpOutputNewFlags" -v -count=1 && go build ./... - cmd/netsynth/main.go contains `rootCmd.Flags().StringVar(&bpfFilter, "filter",` - cmd/netsynth/main.go contains `rootCmd.Flags().StringVar(&readPath, "read",` - cmd/netsynth/main.go contains `"--read and -i are mutually exclusive"` - cmd/netsynth/main.go contains `func deriveOutputPath(readPath string) string` - cmd/netsynth/main.go contains `capture.ValidateBPFFilter(bpfFilter)` - cmd/netsynth/main.go contains `capture.ReadPcapFile(readPath, bpfFilter)` - cmd/netsynth/main.go contains `aggregate.AggregatePcap(classified, aggregate.DefaultWindowMs` - cmd/netsynth/main.go contains `cp.Timestamp = pkt.Metadata().CaptureInfo.Timestamp` - cmd/netsynth/main.go contains `fmt.Fprintf(os.Stderr, "Reading %s...\n", readPath)` (D-06) - cmd/netsynth/main.go contains `capture.StartCapture(ctx, ifaceName, bpfFilter)` - cmd/netsynth/main.go contains `deriveOutputPath(readPath)` (D-05) - cmd/netsynth/main.go contains `"pcap file %q contains no packets"` (Pitfall 4) - cmd/netsynth/main_test.go contains `TestFlagMutualExclusion` - cmd/netsynth/main_test.go contains `TestDeriveOutputPath` - `go test ./cmd/netsynth/... -v` passes all new tests - `go build ./...` succeeds - `go test ./... -count=1` all green (full suite) Both --filter and --read flags are wired into the CLI. Mutual exclusion validated (D-03). BPF filter pre-validated before capture (CAPT-05). Pcap mode reads file, classifies with timestamps, aggregates with AggregatePcap, encodes MP3 (CAPT-06). Output filename derived from input (D-05). Bookend messages displayed (D-06). --verbose works in pcap mode (D-07). All tests pass, all packages compile. Full suite verification: ```bash cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./... -count=1 -v && go build ./... ```

Phase success criteria from ROADMAP:

  1. netsynth -i eth0 --filter "port 53" — filter applied to live capture (requires privileges to test live)
  2. netsynth --read capture.pcap -o out.mp3 — pcap mode produces valid MP3 (testable without privileges)
  3. Invalid BPF filter produces clear error before capture — verified by unit test

<success_criteria>

  1. --filter flag registered and functional: pre-validates BPF, applies to live handle, applies software BPF to pcap
  2. --read flag registered and functional: reads pcap, classifies with timestamps, aggregates, encodes MP3
  3. --read and -i mutually exclusive with clear error
  4. --read without -o derives output from input filename (D-05)
  5. Bookend messages: "Reading ..." at start, summary + "Saved" at end (D-06)
  6. --verbose works with --read (D-07)
  7. Invalid BPF filter errors before capture (CAPT-05)
  8. Empty pcap file produces clear error with file path (Pitfall 4)
  9. go test ./... -count=1 all green
  10. go build ./... succeeds </success_criteria>
After completion, create `.planning/phases/04-power-user-features/04-02-SUMMARY.md`