From 38c74415edbd6e0fd4556c8475c70a8b7a5ce786 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 14:25:52 +0100 Subject: [PATCH] docs(04): create phase plan --- .planning/ROADMAP.md | 8 +- .../04-power-user-features/04-01-PLAN.md | 383 ++++++++++++++++++ .../04-power-user-features/04-02-PLAN.md | 332 +++++++++++++++ 3 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/04-power-user-features/04-01-PLAN.md create mode 100644 .planning/phases/04-power-user-features/04-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 409bcf7..37f7710 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -76,7 +76,11 @@ Plans: 1. User can run `netsynth -i eth0 --filter "port 53"` and only DNS traffic is captured and sonified 2. User can run `netsynth --read capture.pcap -o out.mp3` against an existing pcap file and receive a valid MP3 3. An invalid BPF filter expression produces a clear error message before any capture begins -**Plans**: TBD +**Plans:** 2 plans + +Plans: +- [ ] 04-01-PLAN.md — BPF validation, pcap file reading, timestamp-based aggregation (core library functions) +- [ ] 04-02-PLAN.md — Wire --filter and --read flags into CLI with branching run logic ## Progress @@ -88,4 +92,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 | 1. Capture and Classification | 4/4 | Complete | 2026-03-25 | | 2. Audio Synthesis Engine | 3/3 | Complete | 2026-03-26 | | 3. Pipeline Integration and MVP | 2/2 | Complete | 2026-03-26 | -| 4. Power User Features | 0/? | Not started | - | +| 4. Power User Features | 0/2 | Not started | - | diff --git a/.planning/phases/04-power-user-features/04-01-PLAN.md b/.planning/phases/04-power-user-features/04-01-PLAN.md new file mode 100644 index 0000000..7463e3f --- /dev/null +++ b/.planning/phases/04-power-user-features/04-01-PLAN.md @@ -0,0 +1,383 @@ +--- +phase: 04-power-user-features +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - classify/types.go + - capture/capture.go + - capture/bpf.go + - capture/pcap_reader.go + - aggregate/window.go + - capture/capture_test.go + - capture/bpf_test.go + - capture/pcap_reader_test.go + - aggregate/window_test.go +autonomous: true +requirements: [CAPT-05, CAPT-06] + +must_haves: + truths: + - "BPF filter expressions can be validated without a live socket" + - "A valid pcap file can be read and packets emitted on a channel" + - "Invalid/missing pcap files produce clear error messages" + - "Pcap packets are aggregated into time windows using packet timestamps, not wall clock" + - "Gap windows (no packets) produce empty snapshots preserving silence" + - "BPF filter can be applied to live capture handles" + - "Software BPF filtering works for pcap file packets" + artifacts: + - path: "capture/bpf.go" + provides: "BPF validation and software filter compilation" + exports: ["ValidateBPFFilter", "CompileSoftwareBPF"] + - path: "capture/pcap_reader.go" + provides: "Pcap file reading into packet channel" + exports: ["ReadPcapFile"] + - path: "aggregate/window.go" + provides: "Timestamp-based aggregation for pcap mode" + exports: ["AggregatePcap"] + - path: "classify/types.go" + provides: "Timestamp field on ClassifiedPacket" + contains: "Timestamp time.Time" + key_links: + - from: "capture/pcap_reader.go" + to: "gopacket/pcapgo" + via: "pcapgo.NewReader" + pattern: "pcapgo\\.NewReader" + - from: "capture/bpf.go" + to: "packetcap/go-pcap/filter" + via: "filter.NewExpression" + pattern: "filter\\.NewExpression" + - from: "aggregate/window.go" + to: "classify/types.go" + via: "ClassifiedPacket.Timestamp for window assignment" + pattern: "ev\\.Timestamp" +--- + + +Add core library functions for BPF filtering and pcap file reading. + +Purpose: Build the foundation that Plan 02 wires into the CLI. BPF validation, pcap reading, software BPF matching, and timestamp-based aggregation are all independently testable library functions. +Output: New files capture/bpf.go, capture/pcap_reader.go; updated classify/types.go (Timestamp field), updated capture/capture.go (filter param on OpenCapture), updated aggregate/window.go (AggregatePcap); all with tests. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + +@capture/capture.go +@capture/capture_test.go +@classify/types.go +@classify/classifier.go +@aggregate/window.go +@aggregate/window_test.go + + + + +From classify/types.go: +```go +type TrafficClass string +type ClassifiedPacket struct { + Class TrafficClass + SrcPort uint16 + DstPort uint16 + Protocol string + Length int +} +type WindowSnapshot struct { + Counts map[TrafficClass]int64 + TotalPackets int64 + WindowIndex int +} +``` + +From capture/capture.go: +```go +func OpenCapture(ctx context.Context, iface string) (*pcap.Handle, error) +func StartCapture(ctx context.Context, iface string) (<-chan gopacket.Packet, *int64, error) +``` + +From aggregate/window.go: +```go +const DefaultWindowMs = 500 +func Aggregate(done <-chan struct{}, events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) <-chan classify.WindowSnapshot +``` + +From go-pcap (verified in module cache): +```go +// pcap.Handle method: +func (h *Handle) SetBPFFilter(expr string) error + +// filter package: +func NewExpression(expr string) *Expression +func (e *Expression) Compile() *Filter +func (f *Filter) Compile() ([]bpf.Instruction, error) +``` + +From gopacket/pcapgo (verified in module cache): +```go +func NewReader(r io.Reader) (*Reader, error) +func (r *Reader) LinkType() layers.LinkType +// Reader implements gopacket.PacketDataSource +``` + +From golang.org/x/net/bpf: +```go +func NewVM(filter []bpf.Instruction) (*VM, error) +func (vm *VM) Run(in []byte) (int, error) // >0 means packet passes +``` + + + + + + + Task 1: Add Timestamp to ClassifiedPacket, BPF validation, pcap reading, and software BPF filter + classify/types.go, capture/bpf.go, capture/pcap_reader.go, capture/capture.go, capture/bpf_test.go, capture/pcap_reader_test.go, capture/capture_test.go + classify/types.go, capture/capture.go, capture/capture_test.go, classify/classifier.go + + - TestValidateBPFFilter: ValidateBPFFilter("port 53") returns nil; ValidateBPFFilter("invalid garbage xyz") returns non-nil error containing "invalid BPF filter" + - TestValidateBPFFilterEmpty: ValidateBPFFilter("") returns nil (empty = no filter) + - TestCompileSoftwareBPF: CompileSoftwareBPF("tcp") returns a non-nil *bpf.VM and nil error + - TestCompileSoftwareBPFInvalid: CompileSoftwareBPF("invalid garbage") returns nil VM and non-nil error + - TestReadPcapFile: ReadPcapFile with a programmatically-generated pcap (using pcapgo.NewWriter to write 3 test packets) returns a channel that yields exactly 3 packets, then closes + - TestReadPcapFileNotFound: ReadPcapFile("/nonexistent/file.pcap", "") returns error containing "cannot open" + - TestReadPcapFileInvalid: ReadPcapFile on a non-pcap file (e.g., a temp file with garbage bytes) returns error containing "invalid pcap file" or "not a valid pcap" + - TestReadPcapFileWithFilter: ReadPcapFile with filter="tcp" on a pcap containing both TCP and UDP packets returns only TCP packets + - TestOpenCaptureWithFilter: OpenCapture now accepts a filter string parameter (signature change verified by compilation) + - TestClassifiedPacketTimestamp: ClassifiedPacket struct has a Timestamp field of type time.Time + + + **1. Update classify/types.go** — Add `Timestamp time.Time` field to `ClassifiedPacket`: + ```go + import "time" + + type ClassifiedPacket struct { + Class TrafficClass + SrcPort uint16 + DstPort uint16 + Protocol string + Length int + Timestamp time.Time // Set from pkt.Metadata().CaptureInfo.Timestamp; zero in live mode + } + ``` + This is a backward-compatible addition. Live mode leaves it zero-valued (no behavior change). Per D-01, pcap mode uses it for window assignment. + + **2. Create capture/bpf.go** — Two exported functions: + + ```go + package capture + + import ( + "fmt" + "strings" + + "golang.org/x/net/bpf" + gpcapfilter "github.com/packetcap/go-pcap/filter" + ) + + // ValidateBPFFilter checks if a BPF expression string is valid without needing a live socket. + // Returns nil for empty strings (empty = no filter). Per CAPT-05. + func ValidateBPFFilter(expr string) error { + if strings.TrimSpace(expr) == "" { + return nil + } + e := gpcapfilter.NewExpression(expr) + if e == nil { + return fmt.Errorf("invalid BPF filter expression: %q", expr) + } + compiled := e.Compile() + if _, err := compiled.Compile(); err != nil { + return fmt.Errorf("invalid BPF filter %q: %v", expr, err) + } + return nil + } + + // CompileSoftwareBPF compiles a BPF expression into a VM for user-space packet matching. + // Used for --read mode where kernel BPF is unavailable. Per D-04 (filter works with --read). + func CompileSoftwareBPF(expr string) (*bpf.VM, error) { + e := gpcapfilter.NewExpression(expr) + if e == nil { + return nil, fmt.Errorf("invalid BPF filter expression: %q", expr) + } + instructions, err := e.Compile().Compile() + if err != nil { + return nil, fmt.Errorf("BPF compile error for %q: %v", expr, err) + } + return bpf.NewVM(instructions) + } + ``` + + **3. Create capture/pcap_reader.go** — ReadPcapFile function: + + ```go + package capture + + import ( + "fmt" + "os" + + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" + "github.com/gopacket/gopacket/pcapgo" + "golang.org/x/net/bpf" + ) + + // ReadPcapFile opens a pcap file and returns a channel of packets. + // If filter is non-empty, applies software BPF filtering (kernel BPF unavailable for files). + // Per D-03: replaces -i as packet source. Per D-04: --filter works with --read. + func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("cannot open pcap file %q: %w", path, err) + } + r, err := pcapgo.NewReader(f) + if err != nil { + f.Close() + return nil, fmt.Errorf("not a valid pcap file %q: %w", path, err) + } + + // Compile software BPF filter if provided + var vm *bpf.VM + if filter != "" { + vm, err = CompileSoftwareBPF(filter) + if err != nil { + f.Close() + return nil, err + } + } + + lt := layers.LinkType(r.LinkType()) + packetSource := gopacket.NewPacketSource(r, lt) + packetSource.NoCopy = false // CRITICAL: do NOT use NoCopy=true with pcapgo (Pitfall 3) + + packets := make(chan gopacket.Packet, 512) + go func() { + defer close(packets) + defer f.Close() + for pkt := range packetSource.Packets() { + // Apply software BPF filter if active + if vm != nil { + result, err := vm.Run(pkt.Data()) + if err != nil || result == 0 { + continue // packet does not match filter + } + } + packets <- pkt + } + }() + return packets, nil + } + ``` + + **4. Update capture/capture.go** — Add `filter string` parameter to `OpenCapture` and `StartCapture`. In `OpenCapture`, after successfully opening the handle, call `handle.SetBPFFilter(filter)` if filter is non-empty. If SetBPFFilter fails, close the handle and return error wrapped with `fmt.Errorf("invalid BPF filter %q: %w", filter, err)`. Update `StartCapture` signature to `StartCapture(ctx context.Context, iface string, filter string)` and pass filter through to `OpenCapture`. + + **5. Create test files:** + - `capture/bpf_test.go` — Tests for ValidateBPFFilter and CompileSoftwareBPF per behavior list above. + - `capture/pcap_reader_test.go` — Uses `pcapgo.NewWriter` to create temporary pcap files programmatically (no binary fixtures). Write a helper `createTestPcap(t *testing.T, packets [][]byte) string` that creates a temp file with pcapgo.NewWriter, writes Ethernet+IP+TCP/UDP raw packets, and returns the path. Test ReadPcapFile with valid pcap, nonexistent file, invalid file (garbage bytes), and filter. + - `capture/capture_test.go` — Add TestOpenCaptureSignature that verifies the new signature compiles (the existing tests don't call OpenCapture directly due to privilege requirements; just ensure the file compiles with the new signature). + + **6. Update cmd/netsynth/main.go call site** — The call `capture.StartCapture(ctx, ifaceName)` must become `capture.StartCapture(ctx, ifaceName, "")` to match the updated signature. This is a minimal change to keep existing code compiling. (The actual --filter flag wiring happens in Plan 02.) + + + cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./capture/... -run "TestValidateBPF|TestCompileSoftware|TestReadPcap|TestClassifiedPacket" -v -count=1 && go build ./... + + + - classify/types.go contains `Timestamp time.Time` inside ClassifiedPacket struct + - capture/bpf.go contains `func ValidateBPFFilter(expr string) error` + - capture/bpf.go contains `func CompileSoftwareBPF(expr string) (*bpf.VM, error)` + - capture/pcap_reader.go contains `func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error)` + - capture/pcap_reader.go contains `packetSource.NoCopy = false` + - capture/capture.go contains `func OpenCapture(ctx context.Context, iface string, filter string)` + - capture/capture.go contains `handle.SetBPFFilter(filter)` + - capture/capture.go contains `func StartCapture(ctx context.Context, iface string, filter string)` + - cmd/netsynth/main.go contains `capture.StartCapture(ctx, ifaceName, "")` + - `go test ./capture/... -v` passes all new tests + - `go build ./...` succeeds (all packages compile) + + BPF validation rejects invalid expressions and accepts valid ones. Pcap reader opens valid files and returns packet channels, rejects invalid/missing files. Software BPF filters pcap packets. OpenCapture/StartCapture accept filter parameter. ClassifiedPacket has Timestamp field. All tests pass, all packages compile. + + + + Task 2: Timestamp-based pcap aggregation with gap-filling + aggregate/window.go, aggregate/window_test.go + aggregate/window.go, aggregate/window_test.go, classify/types.go + + - TestAggregatePcapBasic: AggregatePcap with 3 packets at times T+0ms, T+100ms, T+200ms (all in window 0 for 500ms windows) returns 1 snapshot with TotalPackets=3 + - TestAggregatePcapMultipleWindows: AggregatePcap with packets at T+0ms, T+600ms, T+1200ms returns 3 snapshots (window 0, 1, 2) each with TotalPackets=1 + - TestAggregatePcapGaps: AggregatePcap with packets at T+0ms and T+1500ms (skip window 1 and 2) returns 4 snapshots: window 0 with 1 packet, windows 1-2 with TotalPackets=0, window 3 with 1 packet (per D-02: gaps are silent) + - TestAggregatePcapEmpty: AggregatePcap with empty channel returns empty slice (no snapshots) + - TestAggregatePcapWindowIndex: Each returned snapshot has correct sequential WindowIndex 0,1,2,... + - TestAggregatePcapClassCounts: Packets of different classes in same window have correct per-class counts in snapshot.Counts + - TestAggregatePcapOnSnapshot: If onSnapshot callback is provided, it is called for each emitted snapshot (for --verbose support per D-07) + + + Add `AggregatePcap` function to `aggregate/window.go`. This function reads from a `<-chan classify.ClassifiedPacket` (same channel type as live mode), collects all events into a slice, then assigns each to a time window using `pkt.Timestamp.Sub(firstTimestamp).Milliseconds() / windowMs`. + + **Function signature:** + ```go + // AggregatePcap reads all ClassifiedPackets (with Timestamp set), assigns to time windows + // using packet timestamps per D-01, fills gap windows with empty snapshots per D-02, + // and returns all WindowSnapshots. Calls onSnapshot for each if non-nil (D-07: --verbose). + // Returns synchronously after channel closes (pcap processing is finite). + func AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) []classify.WindowSnapshot + ``` + + **Implementation details:** + 1. Drain the `events` channel into a `[]classify.ClassifiedPacket` slice. + 2. If slice is empty, return `nil` (empty slice). + 3. Find `minTimestamp` by scanning all events (handles non-monotonic pcaps per Pitfall 5). + 4. Compute `maxWindowIdx` = `int(maxTimestamp.Sub(minTimestamp).Milliseconds()) / windowMs`. + 5. Create `snapshots := make([]classify.WindowSnapshot, maxWindowIdx+1)`. Initialize each with `Counts: make(map[classify.TrafficClass]int64)` and `WindowIndex: i`. + 6. For each event, compute `idx := int(ev.Timestamp.Sub(minTimestamp).Milliseconds()) / windowMs`. Clamp to `[0, maxWindowIdx]` for safety. Increment `snapshots[idx].Counts[ev.Class]++` and `snapshots[idx].TotalPackets++`. + 7. If `onSnapshot` is non-nil, call it for each snapshot in order (supports --verbose per D-07). + 8. Return `snapshots`. + + **Tests in aggregate/window_test.go** — Add test functions per behavior list. Use helper that creates `classify.ClassifiedPacket` values with specific `Timestamp` values relative to a base time `time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)`. Send packets into a buffered channel, close it, then call `AggregatePcap`. + + + cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./aggregate/... -run "TestAggregatePcap" -v -count=1 + + + - aggregate/window.go contains `func AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) []classify.WindowSnapshot` + - aggregate/window_test.go contains `TestAggregatePcapBasic` + - aggregate/window_test.go contains `TestAggregatePcapGaps` + - aggregate/window_test.go contains `TestAggregatePcapEmpty` + - `go test ./aggregate/... -run TestAggregatePcap -v` shows all tests PASS + - Gap test verifies that windows with no packets have TotalPackets=0 + + AggregatePcap assigns packets to correct time windows using timestamps (D-01). Gap windows produce empty snapshots with TotalPackets=0 (D-02). onSnapshot callback fires for each window (D-07). All aggregation tests pass. + + + + + +Full package compilation and test suite: +```bash +cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./... -count=1 && go build ./... +``` +All existing tests must continue to pass. All new tests must pass. `go build ./...` must succeed. + + + +1. `ValidateBPFFilter("port 53")` returns nil; `ValidateBPFFilter("invalid xyz")` returns error +2. `ReadPcapFile` reads programmatically-generated pcap files and emits correct packet count +3. `ReadPcapFile` with filter only passes matching packets +4. `AggregatePcap` assigns packets to correct windows and fills gaps with empty snapshots +5. `OpenCapture` and `StartCapture` accept filter parameter +6. `ClassifiedPacket` has `Timestamp time.Time` field +7. `go test ./...` all green, `go build ./...` succeeds + + + +After completion, create `.planning/phases/04-power-user-features/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-power-user-features/04-02-PLAN.md b/.planning/phases/04-power-user-features/04-02-PLAN.md new file mode 100644 index 0000000..e731425 --- /dev/null +++ b/.planning/phases/04-power-user-features/04-02-PLAN.md @@ -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 ...' 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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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): +```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 +``` + + + + + + + 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 , --read , 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 + + + +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 + + + +After completion, create `.planning/phases/04-power-user-features/04-02-SUMMARY.md` +