docs(01-capture-and-classification): create phase plan
This commit is contained in:
@@ -29,7 +29,13 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
3. User running without root/CAP_NET_RAW sees a clear error message with a `sudo` hint — not a panic or silent failure
|
3. User running without root/CAP_NET_RAW sees a clear error message with a `sudo` hint — not a panic or silent failure
|
||||||
4. On exit, user sees a per-protocol packet count summary printed to stderr
|
4. On exit, user sees a per-protocol packet count summary printed to stderr
|
||||||
5. User can pass `--verbose` and see per-window protocol activity lines on stderr
|
5. User can pass `--verbose` and see per-window protocol activity lines on stderr
|
||||||
**Plans**: TBD
|
**Plans:** 4 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 01-01-PLAN.md — Go 1.24 setup, module init, shared types, config-driven classifier with tests
|
||||||
|
- [ ] 01-02-PLAN.md — Capture package: OpenCapture, ListInterfaces, privilege error handling
|
||||||
|
- [ ] 01-03-PLAN.md — Aggregation: time-windowed accumulator, exit summary, verbose output
|
||||||
|
- [ ] 01-04-PLAN.md — CLI wiring: Cobra commands, signal handling, pipeline assembly, smoke test
|
||||||
|
|
||||||
### Phase 2: Audio Synthesis Engine
|
### Phase 2: Audio Synthesis Engine
|
||||||
**Goal**: The synthesis and encoding stack produces a valid MP3 from synthetic WindowSnapshot inputs — audio pipeline fully validated before any real traffic flows through it
|
**Goal**: The synthesis and encoding stack produces a valid MP3 from synthetic WindowSnapshot inputs — audio pipeline fully validated before any real traffic flows through it
|
||||||
@@ -71,7 +77,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|
|||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Capture and Classification | 0/? | Not started | - |
|
| 1. Capture and Classification | 0/4 | Planning complete | - |
|
||||||
| 2. Audio Synthesis Engine | 0/? | Not started | - |
|
| 2. Audio Synthesis Engine | 0/? | Not started | - |
|
||||||
| 3. Pipeline Integration and MVP | 0/? | Not started | - |
|
| 3. Pipeline Integration and MVP | 0/? | Not started | - |
|
||||||
| 4. Power User Features | 0/? | Not started | - |
|
| 4. Power User Features | 0/? | Not started | - |
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"text_mode": false,
|
"text_mode": false,
|
||||||
"research_before_questions": false,
|
"research_before_questions": false,
|
||||||
"discuss_mode": "discuss",
|
"discuss_mode": "discuss",
|
||||||
"skip_discuss": false
|
"skip_discuss": false,
|
||||||
|
"_auto_chain_active": false
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"context_warnings": true
|
"context_warnings": true
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
---
|
||||||
|
phase: 01-capture-and-classification
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
- classify/rules.go
|
||||||
|
- classify/classifier.go
|
||||||
|
- classify/classifier_test.go
|
||||||
|
- classify/types.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CLAS-01]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Each of 10+ protocol classes (ICMP, DNS, HTTPS, HTTP, SSH, SMTP, NTP, DHCP, other-TCP, other-UDP, unknown) is correctly identified from packet headers"
|
||||||
|
- "Classification rules are stored in a config-driven slice/struct, not hardcoded switch statements"
|
||||||
|
- "Unrecognized traffic is grouped as 'unknown' class"
|
||||||
|
artifacts:
|
||||||
|
- path: "classify/types.go"
|
||||||
|
provides: "TrafficClass type, ClassifiedPacket struct, WindowSnapshot struct"
|
||||||
|
exports: ["TrafficClass", "ClassifiedPacket", "WindowSnapshot"]
|
||||||
|
- path: "classify/rules.go"
|
||||||
|
provides: "Rule struct and DefaultRules slice with 10+ protocol rules"
|
||||||
|
contains: "DefaultRules"
|
||||||
|
- path: "classify/classifier.go"
|
||||||
|
provides: "Classify function that matches packets against rule table"
|
||||||
|
exports: ["Classify", "NewClassifier"]
|
||||||
|
- path: "classify/classifier_test.go"
|
||||||
|
provides: "Unit tests for all 10+ protocol classes"
|
||||||
|
min_lines: 80
|
||||||
|
key_links:
|
||||||
|
- from: "classify/classifier.go"
|
||||||
|
to: "classify/rules.go"
|
||||||
|
via: "Classifier uses DefaultRules slice"
|
||||||
|
pattern: "DefaultRules"
|
||||||
|
- from: "classify/classifier.go"
|
||||||
|
to: "classify/types.go"
|
||||||
|
via: "Returns TrafficClass from Classify function"
|
||||||
|
pattern: "TrafficClass"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Bootstrap Go project and implement the protocol classification engine with full test coverage.
|
||||||
|
|
||||||
|
Purpose: Establish the Go module, core types shared across all packages, and the config-driven classifier that maps packets to one of 10+ traffic classes. This is the foundation every other plan depends on.
|
||||||
|
|
||||||
|
Output: Compiling Go module with classify package, passing unit tests for all protocol classes.
|
||||||
|
</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/01-capture-and-classification/01-CONTEXT.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Install Go 1.24, initialize module, and create shared types</name>
|
||||||
|
<files>go.mod, classify/types.go</files>
|
||||||
|
<read_first>
|
||||||
|
.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
1. Install Go 1.24+ from https://go.dev/dl/ (NOT apt which gives 1.22). Download the linux-arm64 tarball:
|
||||||
|
- wget https://go.dev/dl/go1.24.1.linux-arm64.tar.gz
|
||||||
|
- sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.1.linux-arm64.tar.gz
|
||||||
|
- Ensure /usr/local/go/bin is in PATH (export PATH=$PATH:/usr/local/go/bin)
|
||||||
|
- Verify: `go version` must show go1.24.x
|
||||||
|
|
||||||
|
2. Initialize the Go module:
|
||||||
|
- cd /home/dev/workspace/yoloyolo
|
||||||
|
- go mod init github.com/netsynth/netsynth
|
||||||
|
- mkdir -p classify capture aggregate cmd/netsynth
|
||||||
|
|
||||||
|
3. Create classify/types.go with the following types (these are the contracts for all packages):
|
||||||
|
|
||||||
|
```go
|
||||||
|
package classify
|
||||||
|
|
||||||
|
// TrafficClass represents a classified network traffic category.
|
||||||
|
type TrafficClass string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClassICMP TrafficClass = "ICMP"
|
||||||
|
ClassDNS TrafficClass = "DNS"
|
||||||
|
ClassHTTPS TrafficClass = "HTTPS"
|
||||||
|
ClassHTTP TrafficClass = "HTTP"
|
||||||
|
ClassSSH TrafficClass = "SSH"
|
||||||
|
ClassSMTP TrafficClass = "SMTP"
|
||||||
|
ClassNTP TrafficClass = "NTP"
|
||||||
|
ClassDHCP TrafficClass = "DHCP"
|
||||||
|
ClassOtherTCP TrafficClass = "other-TCP"
|
||||||
|
ClassOtherUDP TrafficClass = "other-UDP"
|
||||||
|
ClassUnknown TrafficClass = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllClasses returns all known traffic classes in display order.
|
||||||
|
func AllClasses() []TrafficClass {
|
||||||
|
return []TrafficClass{
|
||||||
|
ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH,
|
||||||
|
ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP, ClassUnknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifiedPacket pairs a raw packet's classification result with metadata.
|
||||||
|
type ClassifiedPacket struct {
|
||||||
|
Class TrafficClass
|
||||||
|
SrcPort uint16
|
||||||
|
DstPort uint16
|
||||||
|
Protocol string // "tcp", "udp", "icmp"
|
||||||
|
Length int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowSnapshot holds aggregated packet counts for a time window.
|
||||||
|
// This is the contract between the aggregation stage and future audio synthesis (Phase 2).
|
||||||
|
type WindowSnapshot struct {
|
||||||
|
Counts map[TrafficClass]int64
|
||||||
|
TotalPackets int64
|
||||||
|
WindowIndex int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Fetch Phase 1 dependencies:
|
||||||
|
- go get github.com/gopacket/gopacket@v1.5.0
|
||||||
|
- go get github.com/packetcap/go-pcap@latest
|
||||||
|
- go get github.com/spf13/cobra@v1.10.2
|
||||||
|
- go mod tidy
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/yoloyolo && go version | grep -q "go1.24" && go build ./classify/...</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go version` output contains "go1.24"
|
||||||
|
- go.mod contains `module github.com/netsynth/netsynth`
|
||||||
|
- go.mod contains `github.com/gopacket/gopacket`
|
||||||
|
- go.mod contains `github.com/packetcap/go-pcap`
|
||||||
|
- go.mod contains `github.com/spf13/cobra`
|
||||||
|
- classify/types.go contains `type TrafficClass string`
|
||||||
|
- classify/types.go contains `ClassICMP`, `ClassDNS`, `ClassHTTPS`, `ClassHTTP`, `ClassSSH`, `ClassSMTP`, `ClassNTP`, `ClassDHCP`, `ClassOtherTCP`, `ClassOtherUDP`, `ClassUnknown`
|
||||||
|
- classify/types.go contains `type ClassifiedPacket struct`
|
||||||
|
- classify/types.go contains `type WindowSnapshot struct`
|
||||||
|
- `go build ./classify/...` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Go 1.24 installed, module initialized with all Phase 1 deps, classify/types.go compiles with all 11 traffic classes and shared types.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Implement config-driven classifier with tests for all protocol classes</name>
|
||||||
|
<files>classify/rules.go, classify/classifier.go, classify/classifier_test.go</files>
|
||||||
|
<read_first>
|
||||||
|
classify/types.go
|
||||||
|
.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- TestClassifyICMP: ICMP packet (no port) -> ClassICMP
|
||||||
|
- TestClassifyDNS_UDP: UDP dst port 53 -> ClassDNS
|
||||||
|
- TestClassifyDNS_TCP: TCP dst port 53 -> ClassDNS
|
||||||
|
- TestClassifyHTTPS: TCP dst port 443 -> ClassHTTPS
|
||||||
|
- TestClassifyHTTP: TCP dst port 80 -> ClassHTTP
|
||||||
|
- TestClassifySSH: TCP dst port 22 -> ClassSSH
|
||||||
|
- TestClassifySMTP: TCP dst port 25 -> ClassSMTP
|
||||||
|
- TestClassifyNTP: UDP dst port 123 -> ClassNTP
|
||||||
|
- TestClassifyDHCP: UDP dst port 67 or 68 -> ClassDHCP
|
||||||
|
- TestClassifyOtherTCP: TCP dst port 8080 (unmatched) -> ClassOtherTCP
|
||||||
|
- TestClassifyOtherUDP: UDP dst port 9999 (unmatched) -> ClassOtherUDP
|
||||||
|
- TestClassifyUnknown: Packet with no TCP/UDP/ICMP layer -> ClassUnknown
|
||||||
|
- TestRulesAreOrderDependent: First matching rule wins
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create classify/rules.go with Rule struct and DefaultRules per D-01 and D-02:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package classify
|
||||||
|
|
||||||
|
// Rule defines a classification rule. Protocol is "tcp", "udp", or "icmp".
|
||||||
|
// DstPort 0 means match any port for this protocol.
|
||||||
|
// Rules are evaluated in order; first match wins.
|
||||||
|
type Rule struct {
|
||||||
|
Protocol string
|
||||||
|
DstPort uint16
|
||||||
|
Class TrafficClass
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRules is the D-01 protocol map — ordered, first-match-wins.
|
||||||
|
// Per D-02: config-driven slice, not a switch statement.
|
||||||
|
var DefaultRules = []Rule{
|
||||||
|
{Protocol: "icmp", DstPort: 0, Class: ClassICMP},
|
||||||
|
{Protocol: "udp", DstPort: 53, Class: ClassDNS},
|
||||||
|
{Protocol: "tcp", DstPort: 53, Class: ClassDNS},
|
||||||
|
{Protocol: "tcp", DstPort: 443, Class: ClassHTTPS},
|
||||||
|
{Protocol: "tcp", DstPort: 80, Class: ClassHTTP},
|
||||||
|
{Protocol: "tcp", DstPort: 22, Class: ClassSSH},
|
||||||
|
{Protocol: "tcp", DstPort: 25, Class: ClassSMTP},
|
||||||
|
{Protocol: "udp", DstPort: 123, Class: ClassNTP},
|
||||||
|
{Protocol: "udp", DstPort: 67, Class: ClassDHCP},
|
||||||
|
{Protocol: "udp", DstPort: 68, Class: ClassDHCP},
|
||||||
|
// Catch-alls (must be last):
|
||||||
|
{Protocol: "tcp", DstPort: 0, Class: ClassOtherTCP},
|
||||||
|
{Protocol: "udp", DstPort: 0, Class: ClassOtherUDP},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create classify/classifier.go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package classify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gopacket/gopacket"
|
||||||
|
"github.com/gopacket/gopacket/layers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Classifier classifies packets against a rule table.
|
||||||
|
type Classifier struct {
|
||||||
|
rules []Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClassifier creates a classifier with the given rules.
|
||||||
|
func NewClassifier(rules []Rule) *Classifier {
|
||||||
|
return &Classifier{rules: rules}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify inspects a gopacket.Packet and returns a ClassifiedPacket.
|
||||||
|
// It checks ICMP first, then extracts TCP/UDP port info and matches against rules.
|
||||||
|
// Per D-03: returns ClassUnknown if no rule matches.
|
||||||
|
func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket {
|
||||||
|
result := ClassifiedPacket{
|
||||||
|
Class: ClassUnknown,
|
||||||
|
Length: len(pkt.Data()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ICMP
|
||||||
|
if pkt.Layer(layers.LayerTypeICMPv4) != nil || pkt.Layer(layers.LayerTypeICMPv6) != nil {
|
||||||
|
result.Protocol = "icmp"
|
||||||
|
// Match against rules
|
||||||
|
for _, rule := range c.rules {
|
||||||
|
if rule.Protocol == "icmp" {
|
||||||
|
result.Class = rule.Class
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check TCP
|
||||||
|
if tcpLayer := pkt.Layer(layers.LayerTypeTCP); tcpLayer != nil {
|
||||||
|
tcp := tcpLayer.(*layers.TCP)
|
||||||
|
result.Protocol = "tcp"
|
||||||
|
result.SrcPort = uint16(tcp.SrcPort)
|
||||||
|
result.DstPort = uint16(tcp.DstPort)
|
||||||
|
for _, rule := range c.rules {
|
||||||
|
if rule.Protocol == "tcp" && (rule.DstPort == 0 || rule.DstPort == result.DstPort) {
|
||||||
|
result.Class = rule.Class
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check UDP
|
||||||
|
if udpLayer := pkt.Layer(layers.LayerTypeUDP); udpLayer != nil {
|
||||||
|
udp := udpLayer.(*layers.UDP)
|
||||||
|
result.Protocol = "udp"
|
||||||
|
result.SrcPort = uint16(udp.SrcPort)
|
||||||
|
result.DstPort = uint16(udp.DstPort)
|
||||||
|
for _, rule := range c.rules {
|
||||||
|
if rule.Protocol == "udp" && (rule.DstPort == 0 || rule.DstPort == result.DstPort) {
|
||||||
|
result.Class = rule.Class
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// No recognized transport layer -> ClassUnknown per D-03
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Write tests in classify/classifier_test.go using gopacket test packet construction:
|
||||||
|
- Build synthetic packets using gopacket.SerializeBuffer with layers.Ethernet + layers.IPv4 + layers.TCP/UDP/ICMP headers
|
||||||
|
- Test each of the 10+ protocol classes plus ClassUnknown
|
||||||
|
- Test that rule order matters (first match wins)
|
||||||
|
|
||||||
|
Run RED phase first: write all tests, verify they fail (classifier not yet imported or logic wrong).
|
||||||
|
Then GREEN: implement until all pass.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/yoloyolo && go test -v -count=1 ./classify/...</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- classify/rules.go contains `type Rule struct`
|
||||||
|
- classify/rules.go contains `var DefaultRules = []Rule{`
|
||||||
|
- classify/rules.go contains at least 12 Rule entries (10 specific + 2 catch-all)
|
||||||
|
- classify/classifier.go contains `func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket`
|
||||||
|
- classify/classifier.go contains `func NewClassifier(rules []Rule) *Classifier`
|
||||||
|
- classify/classifier.go does NOT contain `switch` keyword (per D-02)
|
||||||
|
- classify/classifier_test.go contains `TestClassify` with subtests for ICMP, DNS, HTTPS, HTTP, SSH, SMTP, NTP, DHCP, OtherTCP, OtherUDP, Unknown
|
||||||
|
- `go test -v -count=1 ./classify/...` exits 0 with all subtests passing
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Classifier passes tests for all 11 traffic classes. Rules stored as config-driven slice (D-02). First-match-wins ordering verified. No switch statements in classification logic.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go version` shows 1.24+
|
||||||
|
- `go build ./classify/...` compiles without errors
|
||||||
|
- `go test -v -count=1 ./classify/...` all tests pass
|
||||||
|
- `go vet ./classify/...` no issues
|
||||||
|
- No import of `google/gopacket` anywhere (must be `gopacket/gopacket`)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Go 1.24 installed and working
|
||||||
|
- Module initialized with gopacket, go-pcap, cobra dependencies
|
||||||
|
- 11 TrafficClass constants defined
|
||||||
|
- ClassifiedPacket and WindowSnapshot types defined (shared contracts)
|
||||||
|
- Config-driven classifier with 12+ rules in a slice (not switch)
|
||||||
|
- All protocol class unit tests passing
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-capture-and-classification/01-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
---
|
||||||
|
phase: 01-capture-and-classification
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["01-01"]
|
||||||
|
files_modified:
|
||||||
|
- capture/capture.go
|
||||||
|
- capture/capture_test.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CAPT-01, CAPT-02, CAPT-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can specify a network interface via -i flag and packets are captured from it"
|
||||||
|
- "User can list available network interfaces via --list-interfaces"
|
||||||
|
- "User without root/CAP_NET_RAW sees platform-specific error with sudo hint"
|
||||||
|
artifacts:
|
||||||
|
- path: "capture/capture.go"
|
||||||
|
provides: "OpenCapture, ListInterfaces, permission error handling"
|
||||||
|
exports: ["OpenCapture", "ListInterfaces", "StartCapture"]
|
||||||
|
- path: "capture/capture_test.go"
|
||||||
|
provides: "Tests for ListInterfaces and permission error messages"
|
||||||
|
min_lines: 60
|
||||||
|
key_links:
|
||||||
|
- from: "capture/capture.go"
|
||||||
|
to: "github.com/packetcap/go-pcap"
|
||||||
|
via: "pcap.OpenLive for live capture"
|
||||||
|
pattern: "pcap\\.OpenLive"
|
||||||
|
- from: "capture/capture.go"
|
||||||
|
to: "net.Interfaces"
|
||||||
|
via: "stdlib for interface enumeration (NOT pcap.FindAllDevs)"
|
||||||
|
pattern: "net\\.Interfaces"
|
||||||
|
- from: "capture/capture.go"
|
||||||
|
to: "runtime.GOOS"
|
||||||
|
via: "OS detection for platform-specific error messages"
|
||||||
|
pattern: "runtime\\.GOOS"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement the capture package: live packet capture via go-pcap, interface listing via net.Interfaces(), and platform-specific privilege error handling.
|
||||||
|
|
||||||
|
Purpose: This package is the data source for the entire pipeline. It wraps go-pcap with proper error handling and provides the channel-based packet source that the classifier consumes.
|
||||||
|
|
||||||
|
Output: capture/ package with OpenCapture, ListInterfaces, StartCapture functions and unit 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/phases/01-capture-and-classification/01-CONTEXT.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From classify/types.go (created in Plan 01): -->
|
||||||
|
```go
|
||||||
|
package classify
|
||||||
|
|
||||||
|
type TrafficClass string
|
||||||
|
// 11 constants: ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH, ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP, ClassUnknown
|
||||||
|
|
||||||
|
type ClassifiedPacket struct {
|
||||||
|
Class TrafficClass
|
||||||
|
SrcPort uint16
|
||||||
|
DstPort uint16
|
||||||
|
Protocol string
|
||||||
|
Length int
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowSnapshot struct {
|
||||||
|
Counts map[TrafficClass]int64
|
||||||
|
TotalPackets int64
|
||||||
|
WindowIndex int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Implement capture package with OpenCapture, ListInterfaces, and StartCapture</name>
|
||||||
|
<files>capture/capture.go, capture/capture_test.go</files>
|
||||||
|
<read_first>
|
||||||
|
classify/types.go
|
||||||
|
go.mod
|
||||||
|
.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- TestListInterfaces: net.Interfaces() returns at least one interface (lo always exists)
|
||||||
|
- TestListInterfacesFormat: Each interface has Name and Flags fields populated
|
||||||
|
- TestPermissionErrorLinux: When runtime.GOOS=="linux" and OpenLive fails with "operation not permitted", error message contains "sudo setcap cap_net_raw+ep"
|
||||||
|
- TestPermissionErrorDarwin: When runtime.GOOS=="darwin" and OpenLive fails with "permission denied", error message contains "sudo netsynth"
|
||||||
|
- TestIsPermissionError: Recognizes "permission denied", "operation not permitted", and "pcap_create" error strings
|
||||||
|
- TestIsPermissionErrorNegative: Does not flag "interface not found" as permission error
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create capture/capture.go with these functions:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/gopacket/gopacket"
|
||||||
|
"github.com/gopacket/gopacket/layers"
|
||||||
|
pcap "github.com/packetcap/go-pcap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListInterfaces returns all network interfaces using Go stdlib.
|
||||||
|
// Does NOT use pcap.FindAllDevs (go-pcap has no such function).
|
||||||
|
// Requires no privileges (CAPT-02).
|
||||||
|
func ListInterfaces() ([]net.Interface, error) {
|
||||||
|
return net.Interfaces()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCapture opens a live capture handle on the given interface.
|
||||||
|
// snaplen=65535, promiscuous=false, timeout=0 (block until packet).
|
||||||
|
// Returns a platform-specific error message if permission is denied (CAPT-04, D-05).
|
||||||
|
func OpenCapture(ctx context.Context, iface string) (*pcap.Handle, error) {
|
||||||
|
handle, err := pcap.OpenLive(ctx, iface, 65535, false, 0, false)
|
||||||
|
if err != nil {
|
||||||
|
if isPermissionError(err) {
|
||||||
|
return nil, permissionErrorMsg(iface)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to open interface %q: %w", iface, err)
|
||||||
|
}
|
||||||
|
return handle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCapture opens a capture handle and returns a channel of gopacket.Packet.
|
||||||
|
// The channel is closed when ctx is cancelled (Ctrl+C) or capture ends.
|
||||||
|
// Uses non-blocking send with drop counter per Pitfall 4 from RESEARCH.md.
|
||||||
|
// Uses dynamic link type detection per Pitfall 6 from RESEARCH.md.
|
||||||
|
func StartCapture(ctx context.Context, iface string) (<-chan gopacket.Packet, *int64, error) {
|
||||||
|
handle, err := OpenCapture(ctx, iface)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lt := layers.LinkType(handle.LinkType())
|
||||||
|
packetSource := gopacket.NewPacketSource(handle, lt)
|
||||||
|
packetSource.NoCopy = true // safe: go-pcap copies internally
|
||||||
|
|
||||||
|
packets := make(chan gopacket.Packet, 512)
|
||||||
|
var droppedPackets int64
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(packets)
|
||||||
|
defer handle.Close()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
pkt, err := packetSource.NextPacket()
|
||||||
|
if err != nil {
|
||||||
|
// Context cancelled or handle closed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case packets <- pkt:
|
||||||
|
default:
|
||||||
|
atomic.AddInt64(&droppedPackets, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return packets, &droppedPackets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPermissionError checks if the error indicates insufficient privileges.
|
||||||
|
func isPermissionError(err error) bool {
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(s, "permission denied") ||
|
||||||
|
strings.Contains(s, "operation not permitted") ||
|
||||||
|
strings.Contains(s, "pcap_create")
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionErrorMsg returns a platform-specific privilege hint (D-05).
|
||||||
|
func permissionErrorMsg(iface string) error {
|
||||||
|
bin, _ := os.Executable()
|
||||||
|
if bin == "" {
|
||||||
|
bin = "netsynth"
|
||||||
|
}
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return fmt.Errorf(
|
||||||
|
"packet capture requires root or CAP_NET_RAW.\n"+
|
||||||
|
"Run as root: sudo %s -i %s\n"+
|
||||||
|
"Or grant capability: sudo setcap cap_net_raw+ep %s",
|
||||||
|
bin, iface, bin)
|
||||||
|
default: // darwin and others
|
||||||
|
return fmt.Errorf(
|
||||||
|
"packet capture requires root privileges.\n"+
|
||||||
|
"Run as root: sudo %s -i %s", bin, iface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create capture/capture_test.go:
|
||||||
|
- TestListInterfaces: Call ListInterfaces(), assert len > 0 (lo always exists), assert first interface has non-empty Name
|
||||||
|
- TestIsPermissionError: Table-driven test with positive cases ("permission denied", "operation not permitted", "pcap_create: foo") and negative cases ("interface not found", "no such device")
|
||||||
|
- TestPermissionErrorMsg: Call permissionErrorMsg("eth0") on current OS, verify the error string contains "sudo" and the interface name "eth0". On linux verify it contains "setcap cap_net_raw+ep"
|
||||||
|
- NOTE: Do NOT test OpenCapture or StartCapture directly in unit tests -- they require root/CAP_NET_RAW. Those are integration-tested manually via `sudo ./netsynth -i eth0`.
|
||||||
|
|
||||||
|
Write tests first (RED), then implement (GREEN).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/yoloyolo && go test -v -count=1 ./capture/...</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- capture/capture.go contains `func ListInterfaces() ([]net.Interface, error)`
|
||||||
|
- capture/capture.go contains `func OpenCapture(ctx context.Context, iface string) (*pcap.Handle, error)`
|
||||||
|
- capture/capture.go contains `func StartCapture(ctx context.Context, iface string) (<-chan gopacket.Packet, *int64, error)`
|
||||||
|
- capture/capture.go contains `net.Interfaces()` (NOT pcap.FindAllDevs)
|
||||||
|
- capture/capture.go contains `runtime.GOOS` for platform detection
|
||||||
|
- capture/capture.go contains `"setcap cap_net_raw+ep"` in linux error branch
|
||||||
|
- capture/capture.go contains `atomic.AddInt64(&droppedPackets, 1)` for non-blocking send
|
||||||
|
- capture/capture.go contains `layers.LinkType(handle.LinkType())` for dynamic link type
|
||||||
|
- capture/capture_test.go contains `TestListInterfaces`
|
||||||
|
- capture/capture_test.go contains `TestIsPermissionError`
|
||||||
|
- capture/capture_test.go contains `TestPermissionErrorMsg`
|
||||||
|
- `go test -v -count=1 ./capture/...` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>capture package compiles and tests pass. ListInterfaces uses net.Interfaces (no pcap dependency for listing). Permission errors show platform-specific sudo/setcap hints. StartCapture uses non-blocking send with drop counter and dynamic link type.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go test -v -count=1 ./capture/...` all tests pass
|
||||||
|
- `go vet ./capture/...` no issues
|
||||||
|
- No import of `gopacket/pcap` (must use `packetcap/go-pcap`)
|
||||||
|
- No import of `google/gopacket` (must use `gopacket/gopacket`)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- ListInterfaces returns system interfaces without privileges
|
||||||
|
- Permission error detection covers "permission denied", "operation not permitted", "pcap_create"
|
||||||
|
- Error messages are platform-specific (Linux: setcap hint, macOS: sudo hint)
|
||||||
|
- StartCapture uses buffered channel (512) with atomic drop counter
|
||||||
|
- Dynamic link type detection (not hardcoded LinkTypeEthernet)
|
||||||
|
- All unit tests passing
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-capture-and-classification/01-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
---
|
||||||
|
phase: 01-capture-and-classification
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["01-01"]
|
||||||
|
files_modified:
|
||||||
|
- aggregate/window.go
|
||||||
|
- aggregate/summary.go
|
||||||
|
- aggregate/window_test.go
|
||||||
|
- aggregate/summary_test.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CLAS-03, CLAS-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "On exit, user sees a per-protocol packet count summary printed to stderr"
|
||||||
|
- "User can pass --verbose and see per-window protocol activity lines on stderr"
|
||||||
|
- "Aggregator accumulates packet counts into time windows and emits WindowSnapshots"
|
||||||
|
artifacts:
|
||||||
|
- path: "aggregate/window.go"
|
||||||
|
provides: "Ticker-driven window aggregator that emits WindowSnapshots"
|
||||||
|
exports: ["Aggregate"]
|
||||||
|
- path: "aggregate/summary.go"
|
||||||
|
provides: "PrintSummary and PrintWindowLine functions for stderr output"
|
||||||
|
exports: ["PrintSummary", "PrintWindowLine"]
|
||||||
|
- path: "aggregate/window_test.go"
|
||||||
|
provides: "Tests for window aggregation logic"
|
||||||
|
min_lines: 50
|
||||||
|
- path: "aggregate/summary_test.go"
|
||||||
|
provides: "Tests for summary formatting and verbose output"
|
||||||
|
min_lines: 60
|
||||||
|
key_links:
|
||||||
|
- from: "aggregate/window.go"
|
||||||
|
to: "classify/types.go"
|
||||||
|
via: "Consumes ClassifiedPacket, produces WindowSnapshot"
|
||||||
|
pattern: "classify\\.ClassifiedPacket"
|
||||||
|
- from: "aggregate/summary.go"
|
||||||
|
to: "classify/types.go"
|
||||||
|
via: "Uses TrafficClass and WindowSnapshot for formatting"
|
||||||
|
pattern: "classify\\.TrafficClass"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement the aggregation and stats reporting package: time-windowed accumulation, exit summary, and verbose per-window output.
|
||||||
|
|
||||||
|
Purpose: This package bridges classified packets and the eventual audio engine (Phase 2). It accumulates per-class counts into time windows, prints verbose activity lines during capture, and prints a final protocol summary on exit.
|
||||||
|
|
||||||
|
Output: aggregate/ package with Aggregate, PrintSummary, PrintWindowLine functions and full unit 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/phases/01-capture-and-classification/01-CONTEXT.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From classify/types.go (created in Plan 01): -->
|
||||||
|
```go
|
||||||
|
package classify
|
||||||
|
|
||||||
|
type TrafficClass string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClassICMP TrafficClass = "ICMP"
|
||||||
|
ClassDNS TrafficClass = "DNS"
|
||||||
|
ClassHTTPS TrafficClass = "HTTPS"
|
||||||
|
ClassHTTP TrafficClass = "HTTP"
|
||||||
|
ClassSSH TrafficClass = "SSH"
|
||||||
|
ClassSMTP TrafficClass = "SMTP"
|
||||||
|
ClassNTP TrafficClass = "NTP"
|
||||||
|
ClassDHCP TrafficClass = "DHCP"
|
||||||
|
ClassOtherTCP TrafficClass = "other-TCP"
|
||||||
|
ClassOtherUDP TrafficClass = "other-UDP"
|
||||||
|
ClassUnknown TrafficClass = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AllClasses() []TrafficClass { ... }
|
||||||
|
|
||||||
|
type ClassifiedPacket struct {
|
||||||
|
Class TrafficClass
|
||||||
|
SrcPort uint16
|
||||||
|
DstPort uint16
|
||||||
|
Protocol string
|
||||||
|
Length int
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowSnapshot struct {
|
||||||
|
Counts map[TrafficClass]int64
|
||||||
|
TotalPackets int64
|
||||||
|
WindowIndex int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Implement ticker-driven window aggregator</name>
|
||||||
|
<files>aggregate/window.go, aggregate/window_test.go</files>
|
||||||
|
<read_first>
|
||||||
|
classify/types.go
|
||||||
|
.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- TestAggregateEmitsSnapshot: Send 5 ClassifiedPackets, trigger window tick, receive WindowSnapshot with correct counts
|
||||||
|
- TestAggregateMultipleClasses: Send packets of 3 different classes, verify snapshot has all 3 with correct counts
|
||||||
|
- TestAggregateDoneFlushesPartial: Close done channel mid-window, verify final partial snapshot is emitted
|
||||||
|
- TestAggregateEmptyWindow: Trigger tick with no packets, verify snapshot has zero counts and TotalPackets==0
|
||||||
|
- TestAggregateWindowIndex: Multiple ticks increment WindowIndex (0, 1, 2...)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create aggregate/window.go implementing the ticker-driven aggregation pattern from RESEARCH.md Pattern 2:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package aggregate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netsynth/netsynth/classify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultWindowMs is the default time window duration in milliseconds.
|
||||||
|
// Claude's discretion per CONTEXT.md.
|
||||||
|
const DefaultWindowMs = 500
|
||||||
|
|
||||||
|
// Aggregate reads ClassifiedPackets from events, accumulates counts per TrafficClass
|
||||||
|
// in time windows of windowMs duration, and emits a WindowSnapshot per window.
|
||||||
|
// When done is closed, flushes the current partial window and closes the output channel.
|
||||||
|
// If verbose is true, calls onSnapshot for each window (used for --verbose stderr output).
|
||||||
|
func Aggregate(done <-chan struct{}, events <-chan classify.ClassifiedPacket,
|
||||||
|
windowMs int, onSnapshot func(classify.WindowSnapshot)) <-chan classify.WindowSnapshot {
|
||||||
|
|
||||||
|
out := make(chan classify.WindowSnapshot, 8)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
ticker := time.NewTicker(time.Duration(windowMs) * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
counts := make(map[classify.TrafficClass]int64)
|
||||||
|
var totalPackets int64
|
||||||
|
windowIndex := 0
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
snap := classify.WindowSnapshot{
|
||||||
|
Counts: counts,
|
||||||
|
TotalPackets: totalPackets,
|
||||||
|
WindowIndex: windowIndex,
|
||||||
|
}
|
||||||
|
if onSnapshot != nil {
|
||||||
|
onSnapshot(snap)
|
||||||
|
}
|
||||||
|
out <- snap
|
||||||
|
counts = make(map[classify.TrafficClass]int64)
|
||||||
|
totalPackets = 0
|
||||||
|
windowIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
flush() // flush final partial window
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
flush()
|
||||||
|
case ev, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counts[ev.Class]++
|
||||||
|
totalPackets++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create aggregate/window_test.go:
|
||||||
|
- Use a done channel and a buffered events channel to control timing without real tickers
|
||||||
|
- For ticker tests, use a short windowMs (10ms) and sleep briefly to trigger ticks
|
||||||
|
- For done-flush tests, close done channel immediately and verify output
|
||||||
|
- Verify WindowSnapshot.Counts map contains expected class keys and values
|
||||||
|
- Verify WindowSnapshot.TotalPackets matches sum of counts
|
||||||
|
- Verify WindowIndex increments across windows
|
||||||
|
|
||||||
|
Write tests first (RED), then implement (GREEN).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/yoloyolo && go test -v -count=1 -race ./aggregate/...</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- aggregate/window.go contains `func Aggregate(done <-chan struct{}, events <-chan classify.ClassifiedPacket`
|
||||||
|
- aggregate/window.go contains `const DefaultWindowMs = 500`
|
||||||
|
- aggregate/window.go contains `time.NewTicker`
|
||||||
|
- aggregate/window.go imports `github.com/netsynth/netsynth/classify`
|
||||||
|
- aggregate/window_test.go contains `TestAggregate` with subtests for snapshot emission, multiple classes, done flush, empty window, and window index
|
||||||
|
- `go test -v -count=1 -race ./aggregate/...` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Aggregate function emits WindowSnapshots on ticker ticks and flushes partial window on done. All timing-based tests pass including race detector.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Implement exit summary and verbose window output formatting</name>
|
||||||
|
<files>aggregate/summary.go, aggregate/summary_test.go</files>
|
||||||
|
<read_first>
|
||||||
|
classify/types.go
|
||||||
|
aggregate/window.go
|
||||||
|
.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- TestPrintSummary: Given totals map {ICMP: 10, DNS: 50, HTTPS: 100}, output contains "ICMP", "DNS", "HTTPS" with correct counts and percentages, plus "TOTAL" line
|
||||||
|
- TestPrintSummaryEmpty: Given empty totals, output contains "TOTAL" with 0 packets
|
||||||
|
- TestPrintSummarySorted: Output classes appear in stable alphabetical or canonical order (not random map order)
|
||||||
|
- TestPrintWindowLine: Given WindowSnapshot with {DNS: 5, HTTPS: 10}, output contains window index, class names, and counts on a single line
|
||||||
|
- TestPrintWindowLineEmpty: Given empty snapshot, output contains window index with no class entries
|
||||||
|
- TestAccumulateTotals: Given multiple WindowSnapshots, AccumulateTotals produces correct cumulative counts
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create aggregate/summary.go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package aggregate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/netsynth/netsynth/classify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrintSummary writes a per-protocol packet count summary to w (typically os.Stderr).
|
||||||
|
// Output format matches tcpdump conventions (CLAS-03).
|
||||||
|
func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64) {
|
||||||
|
var totalPackets int64
|
||||||
|
for _, count := range totals {
|
||||||
|
totalPackets += count
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "\n--- Protocol Summary ---")
|
||||||
|
|
||||||
|
// Sort classes for stable output
|
||||||
|
classes := make([]string, 0, len(totals))
|
||||||
|
for c := range totals {
|
||||||
|
classes = append(classes, string(c))
|
||||||
|
}
|
||||||
|
sort.Strings(classes)
|
||||||
|
|
||||||
|
for _, className := range classes {
|
||||||
|
c := classify.TrafficClass(className)
|
||||||
|
count := totals[c]
|
||||||
|
pct := 0.0
|
||||||
|
if totalPackets > 0 {
|
||||||
|
pct = float64(count) / float64(totalPackets) * 100.0
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " %-15s %8d packets (%5.1f%%)\n", c, count, pct)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " %-15s %8d packets\n", "TOTAL", totalPackets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintWindowLine writes a single verbose per-window activity line to w (CLAS-04).
|
||||||
|
// Format: [window N] CLASS:count CLASS:count ... (total: N)
|
||||||
|
func PrintWindowLine(w io.Writer, snap classify.WindowSnapshot) {
|
||||||
|
fmt.Fprintf(w, "[window %d]", snap.WindowIndex)
|
||||||
|
// Sort for stable output
|
||||||
|
classes := make([]string, 0, len(snap.Counts))
|
||||||
|
for c := range snap.Counts {
|
||||||
|
classes = append(classes, string(c))
|
||||||
|
}
|
||||||
|
sort.Strings(classes)
|
||||||
|
for _, className := range classes {
|
||||||
|
c := classify.TrafficClass(className)
|
||||||
|
if snap.Counts[c] > 0 {
|
||||||
|
fmt.Fprintf(w, " %s:%d", c, snap.Counts[c])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " (total: %d)\n", snap.TotalPackets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccumulateTotals merges a WindowSnapshot's counts into cumulative totals.
|
||||||
|
func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot) {
|
||||||
|
for class, count := range snap.Counts {
|
||||||
|
totals[class] += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create aggregate/summary_test.go:
|
||||||
|
- Use bytes.Buffer as io.Writer to capture output
|
||||||
|
- TestPrintSummary: Verify output contains expected class names, counts, percentages, and TOTAL line
|
||||||
|
- TestPrintSummaryEmpty: Verify output contains "TOTAL" with 0
|
||||||
|
- TestPrintSummarySorted: Verify output lines are in alphabetical order (capture output, split lines, check ordering)
|
||||||
|
- TestPrintWindowLine: Verify format "[window 0] DNS:5 HTTPS:10 (total: 15)"
|
||||||
|
- TestAccumulateTotals: Two snapshots accumulated, verify merged counts
|
||||||
|
|
||||||
|
Write tests first (RED), then implement (GREEN).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/yoloyolo && go test -v -count=1 ./aggregate/...</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- aggregate/summary.go contains `func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64)`
|
||||||
|
- aggregate/summary.go contains `func PrintWindowLine(w io.Writer, snap classify.WindowSnapshot)`
|
||||||
|
- aggregate/summary.go contains `func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot)`
|
||||||
|
- aggregate/summary.go contains `"--- Protocol Summary ---"`
|
||||||
|
- aggregate/summary.go contains `sort.Strings` for stable output ordering
|
||||||
|
- aggregate/summary_test.go contains `TestPrintSummary`, `TestPrintSummaryEmpty`, `TestPrintSummarySorted`
|
||||||
|
- aggregate/summary_test.go contains `TestPrintWindowLine`, `TestAccumulateTotals`
|
||||||
|
- `go test -v -count=1 ./aggregate/...` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>PrintSummary outputs per-protocol counts with percentages and TOTAL to stderr (CLAS-03). PrintWindowLine outputs per-window activity for --verbose mode (CLAS-04). AccumulateTotals correctly merges snapshots. All formatting tests pass with stable sorted output.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go test -v -count=1 -race ./aggregate/...` all tests pass
|
||||||
|
- `go vet ./aggregate/...` no issues
|
||||||
|
- Output format matches tcpdump convention (stderr, per-protocol counts with percentages)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Aggregate emits WindowSnapshots on time ticks and flushes on done
|
||||||
|
- PrintSummary prints per-protocol packet counts with percentages and TOTAL to an io.Writer
|
||||||
|
- PrintWindowLine prints per-window activity for verbose mode
|
||||||
|
- AccumulateTotals correctly accumulates across multiple snapshots
|
||||||
|
- All output is sorted for deterministic display
|
||||||
|
- All unit tests passing (including race detector)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-capture-and-classification/01-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
---
|
||||||
|
phase: 01-capture-and-classification
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: ["01-01", "01-02", "01-03"]
|
||||||
|
files_modified:
|
||||||
|
- cmd/netsynth/main.go
|
||||||
|
- cmd/netsynth/main_test.go
|
||||||
|
autonomous: false
|
||||||
|
requirements: [CAPT-01, CAPT-02, CAPT-04, CLAS-01, CLAS-03, CLAS-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can run `netsynth -i eth0` and see packets being classified live to stderr"
|
||||||
|
- "User can run `netsynth --list-interfaces` and see available interfaces"
|
||||||
|
- "User without root sees platform-specific error with sudo hint"
|
||||||
|
- "On exit (Ctrl+C), user sees per-protocol packet count summary on stderr"
|
||||||
|
- "User can pass `--verbose` and see per-window protocol activity on stderr"
|
||||||
|
artifacts:
|
||||||
|
- path: "cmd/netsynth/main.go"
|
||||||
|
provides: "Cobra CLI wiring, signal handling, pipeline assembly"
|
||||||
|
exports: ["main"]
|
||||||
|
- path: "cmd/netsynth/main_test.go"
|
||||||
|
provides: "Tests for flag parsing and list-interfaces output"
|
||||||
|
min_lines: 30
|
||||||
|
key_links:
|
||||||
|
- from: "cmd/netsynth/main.go"
|
||||||
|
to: "capture/capture.go"
|
||||||
|
via: "StartCapture for packet channel, ListInterfaces for --list-interfaces"
|
||||||
|
pattern: "capture\\.StartCapture|capture\\.ListInterfaces"
|
||||||
|
- from: "cmd/netsynth/main.go"
|
||||||
|
to: "classify/classifier.go"
|
||||||
|
via: "NewClassifier + Classify in pipeline goroutine"
|
||||||
|
pattern: "classify\\.NewClassifier|classifier\\.Classify"
|
||||||
|
- from: "cmd/netsynth/main.go"
|
||||||
|
to: "aggregate/window.go"
|
||||||
|
via: "Aggregate consumes classified packets channel"
|
||||||
|
pattern: "aggregate\\.Aggregate"
|
||||||
|
- from: "cmd/netsynth/main.go"
|
||||||
|
to: "aggregate/summary.go"
|
||||||
|
via: "PrintSummary on exit, PrintWindowLine for verbose callback"
|
||||||
|
pattern: "aggregate\\.PrintSummary|aggregate\\.PrintWindowLine"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Wire all packages into the Cobra CLI: flag parsing, signal handling, capture-classify-aggregate pipeline, verbose output, and exit summary.
|
||||||
|
|
||||||
|
Purpose: This is the final integration that makes `netsynth` a runnable tool. It connects capture -> classify -> aggregate stages via channels, handles Ctrl+C gracefully, and prints the exit summary. All Phase 1 success criteria become observable here.
|
||||||
|
|
||||||
|
Output: Working `netsynth` binary with -i, --list-interfaces, --verbose flags. Checkpoint for manual smoke test.
|
||||||
|
</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/phases/01-capture-and-classification/01-CONTEXT.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-01-SUMMARY.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-02-SUMMARY.md
|
||||||
|
@.planning/phases/01-capture-and-classification/01-03-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From capture/capture.go (Plan 02): -->
|
||||||
|
```go
|
||||||
|
func ListInterfaces() ([]net.Interface, error)
|
||||||
|
func OpenCapture(ctx context.Context, iface string) (*pcap.Handle, error)
|
||||||
|
func StartCapture(ctx context.Context, iface string) (<-chan gopacket.Packet, *int64, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From classify/ (Plan 01): -->
|
||||||
|
```go
|
||||||
|
type TrafficClass string // 11 constants
|
||||||
|
type ClassifiedPacket struct { Class TrafficClass; SrcPort, DstPort uint16; Protocol string; Length int }
|
||||||
|
type WindowSnapshot struct { Counts map[TrafficClass]int64; TotalPackets int64; WindowIndex int }
|
||||||
|
type Classifier struct { ... }
|
||||||
|
func NewClassifier(rules []Rule) *Classifier
|
||||||
|
func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket
|
||||||
|
var DefaultRules []Rule
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From aggregate/ (Plan 03): -->
|
||||||
|
```go
|
||||||
|
const DefaultWindowMs = 500
|
||||||
|
func Aggregate(done <-chan struct{}, events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) <-chan classify.WindowSnapshot
|
||||||
|
func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64)
|
||||||
|
func PrintWindowLine(w io.Writer, snap classify.WindowSnapshot)
|
||||||
|
func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot)
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Wire Cobra CLI with capture-classify-aggregate pipeline and signal handling</name>
|
||||||
|
<files>cmd/netsynth/main.go, cmd/netsynth/main_test.go</files>
|
||||||
|
<read_first>
|
||||||
|
capture/capture.go
|
||||||
|
classify/classifier.go
|
||||||
|
classify/rules.go
|
||||||
|
classify/types.go
|
||||||
|
aggregate/window.go
|
||||||
|
aggregate/summary.go
|
||||||
|
go.mod
|
||||||
|
.planning/phases/01-capture-and-classification/01-RESEARCH.md
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
1. Create cmd/netsynth/main.go with Cobra root command:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netsynth/netsynth/aggregate"
|
||||||
|
"github.com/netsynth/netsynth/capture"
|
||||||
|
"github.com/netsynth/netsynth/classify"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ifaceName string
|
||||||
|
listIfaces bool
|
||||||
|
verbose bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "netsynth",
|
||||||
|
Short: "Sonify live network traffic into ambient audio",
|
||||||
|
Long: "NetSynth captures network traffic, classifies it by protocol, and (in future phases) synthesizes an ambient MP3 soundscape.",
|
||||||
|
RunE: run,
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.Flags().StringVarP(&ifaceName, "interface", "i", "", "Network interface to capture on (required unless --list-interfaces)")
|
||||||
|
rootCmd.Flags().BoolVar(&listIfaces, "list-interfaces", false, "List available network interfaces and exit")
|
||||||
|
rootCmd.Flags().BoolVar(&verbose, "verbose", false, "Print per-window protocol activity to stderr")
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(cmd *cobra.Command, args []string) error {
|
||||||
|
// --list-interfaces mode (CAPT-02)
|
||||||
|
if listIfaces {
|
||||||
|
return runListInterfaces()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require -i flag
|
||||||
|
if ifaceName == "" {
|
||||||
|
return fmt.Errorf("interface required: use -i <interface> or --list-interfaces to see available interfaces")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up signal handling (Ctrl+C)
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
// Stage 1: Capture (CAPT-01)
|
||||||
|
fmt.Fprintf(os.Stderr, "Starting capture on %s... (press Ctrl+C to stop)\n", ifaceName)
|
||||||
|
packets, droppedPtr, err := capture.StartCapture(ctx, ifaceName)
|
||||||
|
if err != nil {
|
||||||
|
return err // CAPT-04: permission error already has platform-specific message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: Classify (CLAS-01)
|
||||||
|
classifier := classify.NewClassifier(classify.DefaultRules)
|
||||||
|
classified := make(chan classify.ClassifiedPacket, 1024)
|
||||||
|
go func() {
|
||||||
|
defer close(classified)
|
||||||
|
for pkt := range packets {
|
||||||
|
classified <- classifier.Classify(pkt)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Stage 3: Aggregate with optional verbose callback (CLAS-04)
|
||||||
|
var onSnapshot func(classify.WindowSnapshot)
|
||||||
|
if verbose {
|
||||||
|
onSnapshot = func(snap classify.WindowSnapshot) {
|
||||||
|
aggregate.PrintWindowLine(os.Stderr, snap) // CLAS-04: --verbose output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshots := aggregate.Aggregate(ctx.Done(), classified, aggregate.DefaultWindowMs, onSnapshot)
|
||||||
|
|
||||||
|
// Consume snapshots and accumulate totals
|
||||||
|
totals := make(map[classify.TrafficClass]int64)
|
||||||
|
for snap := range snapshots {
|
||||||
|
aggregate.AccumulateTotals(totals, snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print exit summary (CLAS-03)
|
||||||
|
dropped := atomic.LoadInt64(droppedPtr)
|
||||||
|
if dropped > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nWarning: %d packets dropped (channel buffer full)\n", dropped)
|
||||||
|
}
|
||||||
|
aggregate.PrintSummary(os.Stderr, totals)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runListInterfaces() error {
|
||||||
|
ifaces, err := capture.ListInterfaces()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error listing interfaces: %w", err)
|
||||||
|
}
|
||||||
|
if len(ifaces) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "No interfaces found. If interfaces are missing, run with sudo.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "Available interfaces:")
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
addrs, _ := iface.Addrs()
|
||||||
|
addrStrs := make([]string, len(addrs))
|
||||||
|
for i, a := range addrs {
|
||||||
|
addrStrs[i] = a.String()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, " %-15s flags=%s addrs=%s\n",
|
||||||
|
iface.Name, iface.Flags, strings.Join(addrStrs, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create cmd/netsynth/main_test.go with:
|
||||||
|
- TestListInterfacesFlag: Execute rootCmd with `--list-interfaces`, verify it exits 0 (integration-lite test)
|
||||||
|
- TestMissingInterfaceFlag: Execute rootCmd with no flags, verify error message contains "interface required"
|
||||||
|
- TestHelpOutput: Execute rootCmd with `--help`, verify output contains "-i", "--list-interfaces", "--verbose"
|
||||||
|
|
||||||
|
3. Build the binary:
|
||||||
|
- `CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth`
|
||||||
|
- Verify: `./netsynth --help` shows usage
|
||||||
|
- Verify: `./netsynth --list-interfaces` shows interfaces
|
||||||
|
- Verify: `./netsynth -i eth0` without root shows permission error with sudo hint
|
||||||
|
|
||||||
|
4. Run full test suite: `go test -v -race ./...`
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/yoloyolo && CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth && ./netsynth --list-interfaces 2>&1 | grep -q "flags=" && go test -v -count=1 ./...</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- cmd/netsynth/main.go contains `rootCmd.Flags().StringVarP(&ifaceName, "interface", "i"`
|
||||||
|
- cmd/netsynth/main.go contains `rootCmd.Flags().BoolVar(&listIfaces, "list-interfaces"`
|
||||||
|
- cmd/netsynth/main.go contains `rootCmd.Flags().BoolVar(&verbose, "verbose"`
|
||||||
|
- cmd/netsynth/main.go contains `signal.NotifyContext`
|
||||||
|
- cmd/netsynth/main.go contains `capture.StartCapture`
|
||||||
|
- cmd/netsynth/main.go contains `classify.NewClassifier(classify.DefaultRules)`
|
||||||
|
- cmd/netsynth/main.go contains `aggregate.Aggregate(`
|
||||||
|
- cmd/netsynth/main.go contains `aggregate.PrintSummary(os.Stderr`
|
||||||
|
- cmd/netsynth/main.go contains `aggregate.PrintWindowLine(os.Stderr`
|
||||||
|
- `CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth` exits 0
|
||||||
|
- `./netsynth --list-interfaces` output contains interface names and "flags="
|
||||||
|
- `./netsynth 2>&1` (no flags) output contains "interface required"
|
||||||
|
- `go test -v -count=1 ./...` exits 0 (all unit tests pass)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>netsynth binary builds and runs. --list-interfaces shows interfaces. Missing -i shows clear error. Signal handling wires Ctrl+C to clean pipeline shutdown. Exit summary prints per-protocol counts. All tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 2: Smoke test live capture on real interface</name>
|
||||||
|
<files>cmd/netsynth/main.go</files>
|
||||||
|
<action>
|
||||||
|
This is a human verification checkpoint. The executor should present the smoke test instructions below to the user and wait for approval. No code changes are needed -- this verifies the work done in Task 1.
|
||||||
|
</action>
|
||||||
|
<what-built>
|
||||||
|
Complete Phase 1 pipeline: netsynth CLI with live packet capture, protocol classification, and stderr output.
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Build: `cd /home/dev/workspace/yoloyolo && CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth`
|
||||||
|
2. List interfaces: `./netsynth --list-interfaces` -- should show eth0 and lo at minimum
|
||||||
|
3. Test permission error (non-root): `./netsynth -i eth0` -- should show sudo/setcap hint
|
||||||
|
4. Test live capture (needs root): `sudo ./netsynth -i eth0 --verbose`
|
||||||
|
- Generate some traffic in another terminal: `curl https://example.com`, `ping -c 3 8.8.8.8`
|
||||||
|
- You should see per-window verbose lines on stderr showing HTTPS, ICMP, DNS counts
|
||||||
|
- Press Ctrl+C
|
||||||
|
- You should see "--- Protocol Summary ---" with per-protocol packet counts and percentages
|
||||||
|
5. Verify no-flag error: `./netsynth` -- should say "interface required"
|
||||||
|
6. Verify help: `./netsynth --help` -- should show -i, --list-interfaces, --verbose flags
|
||||||
|
</how-to-verify>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/dev/workspace/yoloyolo && ./netsynth --list-interfaces 2>&1 | grep -q "flags="</automated>
|
||||||
|
</verify>
|
||||||
|
<done>User confirmed live capture works: packets classified, verbose output shows per-window activity, Ctrl+C produces protocol summary.</done>
|
||||||
|
<resume-signal>Type "approved" if live capture works correctly, or describe issues</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth` compiles successfully
|
||||||
|
- `./netsynth --list-interfaces` shows system interfaces
|
||||||
|
- `./netsynth` (no flags) shows clear error about missing -i flag
|
||||||
|
- `./netsynth --help` shows all three flags
|
||||||
|
- `go test -v -race ./...` all tests pass across all packages
|
||||||
|
- No import of `google/gopacket` anywhere
|
||||||
|
- No switch statements in classify/classifier.go
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- netsynth binary builds with CGO_ENABLED=0 (no CGo needed for Phase 1)
|
||||||
|
- -i flag accepted, --list-interfaces works, --verbose works
|
||||||
|
- Permission error shows platform-specific sudo hint
|
||||||
|
- Live capture classifies packets and prints verbose lines (when --verbose)
|
||||||
|
- Ctrl+C produces clean exit with per-protocol summary
|
||||||
|
- All unit tests pass across classify/, capture/, aggregate/, and cmd/netsynth/
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-capture-and-classification/01-04-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user