333 lines
14 KiB
Markdown
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>
|