18 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-power-user-features | 01 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-power-user-features/04-CONTEXT.md @.planning/phases/04-power-user-features/04-RESEARCH.md@capture/capture.go @capture/capture_test.go @classify/types.go @classify/classifier.go @aggregate/window.go @aggregate/window_test.go
From classify/types.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:
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:
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):
// 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):
func NewReader(r io.Reader) (*Reader, error)
func (r *Reader) LinkType() layers.LinkType
// Reader implements gopacket.PacketDataSource
From golang.org/x/net/bpf:
func NewVM(filter []bpf.Instruction) (*VM, error)
func (vm *VM) Run(in []byte) (int, error) // >0 means packet passes
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.)
**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`.
<success_criteria>
ValidateBPFFilter("port 53")returns nil;ValidateBPFFilter("invalid xyz")returns errorReadPcapFilereads programmatically-generated pcap files and emits correct packet countReadPcapFilewith filter only passes matching packetsAggregatePcapassigns packets to correct windows and fills gaps with empty snapshotsOpenCaptureandStartCaptureaccept filter parameterClassifiedPackethasTimestamp time.Timefieldgo test ./...all green,go build ./...succeeds </success_criteria>