docs(04): create phase plan
This commit is contained in:
@@ -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 | - |
|
||||
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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
|
||||
|
||||
@capture/capture.go
|
||||
@capture/capture_test.go
|
||||
@classify/types.go
|
||||
@classify/classifier.go
|
||||
@aggregate/window.go
|
||||
@aggregate/window_test.go
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. -->
|
||||
|
||||
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
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add Timestamp to ClassifiedPacket, BPF validation, pcap reading, and software BPF filter</name>
|
||||
<files>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</files>
|
||||
<read_first>classify/types.go, capture/capture.go, capture/capture_test.go, classify/classifier.go</read_first>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
**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.)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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 ./...</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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)
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Timestamp-based pcap aggregation with gap-filling</name>
|
||||
<files>aggregate/window.go, aggregate/window_test.go</files>
|
||||
<read_first>aggregate/window.go, aggregate/window_test.go, classify/types.go</read_first>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./aggregate/... -run "TestAggregatePcap" -v -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-power-user-features/04-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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