Files
2026-03-26 14:25:52 +01:00

333 lines
14 KiB
Markdown

---
phase: 04-power-user-features
plan: 02
type: execute
wave: 2
depends_on: [04-01]
files_modified:
- cmd/netsynth/main.go
- cmd/netsynth/main_test.go
autonomous: true
requirements: [CAPT-05, CAPT-06]
must_haves:
truths:
- "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"
artifacts:
- path: "cmd/netsynth/main.go"
provides: "CLI wiring for --filter and --read flags with branching run logic"
contains: ["--filter", "--read", "ReadPcapFile", "AggregatePcap", "deriveOutputPath"]
key_links:
- from: "cmd/netsynth/main.go"
to: "capture/bpf.go"
via: "ValidateBPFFilter call before capture"
pattern: "capture\\.ValidateBPFFilter"
- from: "cmd/netsynth/main.go"
to: "capture/pcap_reader.go"
via: "ReadPcapFile for --read mode"
pattern: "capture\\.ReadPcapFile"
- from: "cmd/netsynth/main.go"
to: "aggregate/window.go"
via: "AggregatePcap for pcap mode"
pattern: "aggregate\\.AggregatePcap"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- From Plan 01 outputs (capture/bpf.go, capture/pcap_reader.go, aggregate/window.go) -->
From capture/bpf.go (created in Plan 01):
```go
func ValidateBPFFilter(expr string) error
func CompileSoftwareBPF(expr string) (*bpf.VM, error)
```
From capture/pcap_reader.go (created in Plan 01):
```go
func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error)
```
From capture/capture.go (updated in Plan 01):
```go
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):
```go
func AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) []classify.WindowSnapshot
```
From classify/types.go (updated in Plan 01):
```go
type ClassifiedPacket struct {
Class TrafficClass
SrcPort uint16
DstPort uint16
Protocol string
Length int
Timestamp time.Time
}
```
Existing from cmd/netsynth/main.go:
```go
var (
ifaceName string
listIfaces bool
verbose bool
outputPath string
)
func run(cmd *cobra.Command, args []string) error
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add --filter and --read flags with branching run logic</name>
<files>cmd/netsynth/main.go, cmd/netsynth/main_test.go</files>
<read_first>cmd/netsynth/main.go, cmd/netsynth/main_test.go, capture/bpf.go, capture/pcap_reader.go, aggregate/window.go</read_first>
<behavior>
- 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"
</behavior>
<action>
**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`.
</action>
<verify>
<automated>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 ./...</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
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
</verification>
<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 <file>..." 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>
<output>
After completion, create `.planning/phases/04-power-user-features/04-02-SUMMARY.md`
</output>