docs(04): create phase plan
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user