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 |
|
|
true |
|
|
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
**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`.
Phase success criteria from ROADMAP:
netsynth -i eth0 --filter "port 53"— filter applied to live capture (requires privileges to test live)netsynth --read capture.pcap -o out.mp3— pcap mode produces valid MP3 (testable without privileges)- Invalid BPF filter produces clear error before capture — verified by unit test
<success_criteria>
--filterflag registered and functional: pre-validates BPF, applies to live handle, applies software BPF to pcap--readflag registered and functional: reads pcap, classifies with timestamps, aggregates, encodes MP3--readand-imutually exclusive with clear error--readwithout-oderives output from input filename (D-05)- Bookend messages: "Reading ..." at start, summary + "Saved" at end (D-06)
--verboseworks with--read(D-07)- Invalid BPF filter errors before capture (CAPT-05)
- Empty pcap file produces clear error with file path (Pitfall 4)
go test ./... -count=1all greengo build ./...succeeds </success_criteria>