Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ cmd/otel-collector/components.go
cmd/otel-collector/main.go
cmd/otel-collector/main_others.go
cmd/otel-collector/main_windows.go
cmd/otel-collector/otel-collector

*.md
!README.md
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ on macOS, `/proc` on Linux).

| Flag | Description |
|------|-------------|
| `-i`, `--iface` | Interface to capture on (default: the one backing the default route) |
| `--include-loopback` | Also capture loopback traffic |
| `-i`, `--iface` | Capture on these interfaces: comma-separated device names, or `any`/`default`/`localhost` (default `any`) |
| `-l`, `--level` | Log level: `debug`, `info`, `warn`, `error` |

## Keys
Expand Down
15 changes: 11 additions & 4 deletions aggregate/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ type Row struct {
// has no such single answer once more than one connection is involved.
Hostname string

// Iface is the interface capture last saw this row's connection's
// traffic on, or "" once capture hasn't yet — the same first-seen
// representative-value trade LocalAddr and Hostname above make.
Iface string

BytesInTotal uint64
BytesOutTotal uint64
RateInBps float64
Expand Down Expand Up @@ -238,14 +243,15 @@ type Snapshot struct {
// build on.
At time.Time
Connections []ConnectionRecord
// SYNEvents, RSTEvents, DNSQueries and DNSErrors are the capture-only
// event streams drained since the previous refresh — unlike Connections,
// these are not retained across ticks, so a consumer that skips a
// Refresh loses whatever accumulated in between.
// SYNEvents, RSTEvents, DNSQueries, DNSErrors and DNSAnswers are the
// capture-only event streams drained since the previous refresh — unlike
// Connections, these are not retained across ticks, so a consumer that
// skips a Refresh loses whatever accumulated in between.
SYNEvents []capture.SYNEvent
RSTEvents []capture.RSTEvent
DNSQueries []dpi.QueryFinding
DNSErrors []dpi.DNSErrorFinding
DNSAnswers []dpi.DNSAnswerFinding
// PacketStats is each capture interface's most recently sampled pcap
// statistics, keyed by interface name — unlike the event streams above,
// this is a point-in-time snapshot, not drained.
Expand Down Expand Up @@ -286,6 +292,7 @@ func (a *Aggregator) Refresh(now time.Time) Snapshot {
snap.RSTEvents = a.cap.DrainRSTEvents()
snap.DNSQueries = a.cap.DrainDNSQueries()
snap.DNSErrors = a.cap.DrainDNSErrors()
snap.DNSAnswers = a.cap.DrainDNSAnswers()
snap.PacketStats = a.cap.PacketStats()

// Drop the counters behind whatever can no longer possibly be on
Expand Down
17 changes: 13 additions & 4 deletions aggregate/aggregator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -537,8 +537,8 @@ func TestRowsByPIDSumsCountersPerProcess(t *testing.T) {
func TestRowsByPIDMergesConnectionsToTheSameDestination(t *testing.T) {
now := time.Now()
snap := Snapshot{Connections: []ConnectionRecord{
{PID: 42, ProcessName: "curl", RemoteAddr: "140.82.112.3", RemotePort: 443, BytesInTotal: 1000, LastSeen: now, Hostname: "first.example.com", FirstSeen: now.Add(-time.Hour)},
{PID: 42, ProcessName: "curl", RemoteAddr: "140.82.112.3", RemotePort: 443, BytesInTotal: 2000, LastSeen: now, Hostname: "second.example.com", FirstSeen: now.Add(-2 * time.Hour)},
{PID: 42, ProcessName: "curl", RemoteAddr: "140.82.112.3", RemotePort: 443, BytesInTotal: 1000, LastSeen: now, Hostname: "first.example.com", Iface: "en0", FirstSeen: now.Add(-time.Hour)},
{PID: 42, ProcessName: "curl", RemoteAddr: "140.82.112.3", RemotePort: 443, BytesInTotal: 2000, LastSeen: now, Hostname: "second.example.com", Iface: "en1", FirstSeen: now.Add(-2 * time.Hour)},
}}

rows := Rows(snap, GroupByPID)
Expand All @@ -553,6 +553,10 @@ func TestRowsByPIDMergesConnectionsToTheSameDestination(t *testing.T) {
if got := rows[0].Hostname; got != "first.example.com" {
t.Errorf("row.Hostname = %q, want %q (first-seen representative value)", got, "first.example.com")
}
// Iface makes the same trade as Hostname/LocalAddr above.
if got := rows[0].Iface; got != "en0" {
t.Errorf("row.Iface = %q, want %q (first-seen representative value)", got, "en0")
}
// FirstSeen, unlike Hostname, does have a single right answer once
// connections are rolled together: the earliest of them.
if got := rows[0].FirstSeen; !got.Equal(now.Add(-2 * time.Hour)) {
Expand All @@ -563,8 +567,8 @@ func TestRowsByPIDMergesConnectionsToTheSameDestination(t *testing.T) {
func TestRowsByProcessNameGroupsAcrossPIDs(t *testing.T) {
now := time.Now()
snap := Snapshot{Connections: []ConnectionRecord{
{PID: 100, ProcessName: "chrome", RemoteAddr: "1.1.1.1", RemotePort: 443, BytesInTotal: 10, LastSeen: now, FirstSeen: now.Add(-2 * time.Hour)},
{PID: 200, ProcessName: "chrome", RemoteAddr: "1.1.1.1", RemotePort: 443, BytesInTotal: 20, LastSeen: now, FirstSeen: now.Add(-time.Hour)},
{PID: 100, ProcessName: "chrome", RemoteAddr: "1.1.1.1", RemotePort: 443, BytesInTotal: 10, LastSeen: now, Iface: "en0", FirstSeen: now.Add(-2 * time.Hour)},
{PID: 200, ProcessName: "chrome", RemoteAddr: "1.1.1.1", RemotePort: 443, BytesInTotal: 20, LastSeen: now, Iface: "en1", FirstSeen: now.Add(-time.Hour)},
}}

rows := Rows(snap, GroupByProcessName)
Expand All @@ -584,6 +588,11 @@ func TestRowsByProcessNameGroupsAcrossPIDs(t *testing.T) {
if !got.FirstSeen.Equal(now.Add(-2 * time.Hour)) {
t.Errorf("row.FirstSeen = %s, want %s (the minimum across the two PIDs)", got.FirstSeen, now.Add(-2*time.Hour))
}
// Iface makes the same first-seen representative-value trade as
// Hostname/LocalAddr.
if got.Iface != "en0" {
t.Errorf("row.Iface = %q, want %q (first-seen representative value)", got.Iface, "en0")
}
}

// TestRowsByProcessNameSplitsByDestination shows the other half of grouping
Expand Down
4 changes: 3 additions & 1 deletion aggregate/rows.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func rowsUngrouped(snap Snapshot) []Row {
Proto: c.Proto,
State: c.State,
Hostname: c.Hostname,
Iface: c.Iface,
Vanished: c.Vanished,
}
})
Expand Down Expand Up @@ -103,6 +104,7 @@ func rowsByPID(snap Snapshot) []Row {
RemoteAddr: c.RemoteAddr,
RemotePort: c.RemotePort,
Hostname: c.Hostname,
Iface: c.Iface,
}
},
)
Expand All @@ -114,7 +116,7 @@ func rowsByProcessName(snap Snapshot) []Row {
return rollup(snap.Connections,
processRemoteKey,
func(c ConnectionRecord, key string) Row {
return Row{Key: key, Label: c.ProcessName, RemoteAddr: c.RemoteAddr, RemotePort: c.RemotePort, Hostname: c.Hostname}
return Row{Key: key, Label: c.ProcessName, RemoteAddr: c.RemoteAddr, RemotePort: c.RemotePort, Hostname: c.Hostname, Iface: c.Iface}
},
)
}
Expand Down
53 changes: 53 additions & 0 deletions capture/dns_answers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package capture

import (
"sync"

"github.com/boyvinall/trafficmon/dpi"
)

// dnsAnswerRingCapacity bounds how many pending DNS answer findings
// dnsAnswerRing holds before the next DrainDNSAnswers — past this, the
// oldest queued finding is dropped to admit the newest, the same trade-off
// every other bounded buffer in this package makes for a consumer that falls
// behind.
const dnsAnswerRingCapacity = 4096

// dnsAnswerRing is a bounded, mutex-guarded queue of dpi.DNSAnswerFinding
// values. push never blocks: past capacity it drops the oldest entry. drain
// swaps out the whole backing slice at once, matching this package's
// convention of replacing shared state wholesale rather than patching it in
// place.
type dnsAnswerRing struct {
mu sync.Mutex
items []dpi.DNSAnswerFinding
}

// newDNSAnswerRing creates an empty dnsAnswerRing.
func newDNSAnswerRing() *dnsAnswerRing {
return &dnsAnswerRing{items: make([]dpi.DNSAnswerFinding, 0, dnsAnswerRingCapacity)}
}

// push appends f, dropping the oldest queued finding first if the ring is
// already at capacity.
func (r *dnsAnswerRing) push(f dpi.DNSAnswerFinding) {
r.mu.Lock()
defer r.mu.Unlock()

if len(r.items) >= dnsAnswerRingCapacity {
copy(r.items, r.items[1:])
r.items = r.items[:len(r.items)-1]
}
r.items = append(r.items, f)
}

// drain returns every finding queued since the last drain and resets the
// ring to empty.
func (r *dnsAnswerRing) drain() []dpi.DNSAnswerFinding {
r.mu.Lock()
defer r.mu.Unlock()

items := r.items
r.items = make([]dpi.DNSAnswerFinding, 0, dnsAnswerRingCapacity)
return items
}
136 changes: 123 additions & 13 deletions capture/iface.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,84 @@ import (
// misbehaving binary cannot stall startup forever.
const routeTimeout = 3 * time.Second

// Interface-spec keywords recognised in Config.Interface (and by
// ResolveInterfaces directly), case-insensitively, alongside literal
// device names in a comma-separated list — e.g. "eth0,loopback". An empty
// spec is treated as Any.
const (
// Any selects every interface libpcap can see. It is DefaultConfig's
// own default.
Any = "any"
// Default selects every interface currently backing a default route —
// there can be more than one, e.g. a different interface for the IPv4
// and IPv6 default routes.
Default = "default"
// Localhost selects the platform's loopback interface. "local" and
// "loopback" are accepted as synonyms in a spec string, but this is
// the one Go callers get a name for.
Localhost = "localhost"
)

// ResolveInterfaces expands an interface spec into the concrete,
// deduplicated set of libpcap device names it names, in first-seen order.
// Unknown keywords are never guessed at: anything that isn't Any, Default,
// Localhost, or one of Localhost's "local"/"loopback" synonyms is taken as
// a literal device name, unresolved and unvalidated until Run actually
// tries to open it.
func ResolveInterfaces(spec string) ([]string, error) {
if strings.TrimSpace(spec) == "" {
spec = Any
}

var (
out []string
seen = make(map[string]struct{})
)
add := func(name string) {
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
out = append(out, name)
}

for _, tok := range strings.Split(spec, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}

switch strings.ToLower(tok) {
case Any:
names, err := ListInterfaces()
if err != nil {
return nil, err
}
for _, n := range names {
add(n)
}
case Default:
names, err := DefaultInterfaces()
if err != nil {
return nil, err
}
for _, n := range names {
add(n)
}
case Localhost, "local", "loopback":
name, err := loopbackDeviceName()
if err != nil {
return nil, err
}
add(name)
default:
add(tok)
}
}

return out, nil
}

// loopbackInterface is the name of the platform's loopback device, and
// runRoute/parseRouteInterface find the interface backing the default route
// by shelling out to the platform's own routing-table tool. All three are
Expand All @@ -27,33 +105,65 @@ const routeTimeout = 3 * time.Second
// interface name and the loopback device has no stable name to compare
// against.

// DefaultInterface resolves the interface backing the default route, mirroring
// what `route get default` reports — the same trick iftop uses to pick an
// interface with no flags given.
func DefaultInterface() (string, error) {
// DefaultInterfaces resolves every interface currently backing a default
// route: whichever carries the IPv4 default route, the IPv6 default
// route, or both if they differ, mirroring what `route get default` /
// `route get -inet6 default` report. Falls back to the first non-loopback
// device libpcap offers if neither route lookup succeeds, same as
// DefaultInterface always has.
func DefaultInterfaces() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), routeTimeout)
defer cancel()

out, err := runRoute(ctx)
if err == nil {
if name, perr := parseRouteInterface(string(out)); perr == nil {
return name, nil
var (
out []string
seen = make(map[string]struct{})
)
add := func(name string) {
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
out = append(out, name)
}

if out4, err := runRoute(ctx); err == nil {
if name, perr := parseRouteInterface(string(out4)); perr == nil {
add(name)
}
}
if out6, err := runRoute6(ctx); err == nil {
if name, perr := parseRouteInterface(string(out6)); perr == nil {
add(name)
}
}
if len(out) > 0 {
return out, nil
}

// No default route, or output we did not recognise: fall back to the
// first real device libpcap offers, which is usually the right guess on
// a machine with a single uplink.
names, err := ListInterfaces()
if err != nil {
return "", err
return nil, err
}
for _, n := range names {
if !isLoopbackInterface(n) {
return n, nil
return []string{n}, nil
}
}
return "", errors.New("no capturable interface found")
return nil, errors.New("no capturable interface found")
}

// DefaultInterface is DefaultInterfaces narrowed to one name, for callers
// that only want a single best guess.
func DefaultInterface() (string, error) {
names, err := DefaultInterfaces()
if err != nil {
return "", err
}
return names[0], nil
}

// localAddrSet collects the IP addresses configured on the named interfaces,
Expand All @@ -67,11 +177,11 @@ func localAddrSet(names []string) (map[netip.Addr]struct{}, error) {
for _, name := range names {
ifi, err := resolveInterface(name)
if err != nil {
return nil, fmt.Errorf("interface %s: %w", name, err)
continue
}
addrs, err := ifi.Addrs()
if err != nil {
return nil, fmt.Errorf("addresses of %s: %w", name, err)
continue
}
for _, a := range addrs {
ipnet, isIPNet := a.(*net.IPNet)
Expand Down
Loading