Skip to content

Commit f77beec

Browse files
committed
broaden IPFIX support for third-party exporters
1 parent b17732a commit f77beec

4 files changed

Lines changed: 779 additions & 93 deletions

File tree

src/networking/ipfix/collect.rs

Lines changed: 208 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
55
use async_channel::Sender;
66
use pcap::Address;
7-
use std::net::{IpAddr, SocketAddr, UdpSocket};
7+
use std::net::{SocketAddr, UdpSocket};
88
use std::time::Instant;
99
use tokio::sync::broadcast::Receiver;
1010

1111
use crate::location;
1212
use crate::mmdb::types::mmdb_reader::MmdbReaders;
1313
use crate::networking::ipfix::templates::TemplateCache;
14+
use crate::networking::ipfix::totals::TotalsCache;
1415
use crate::networking::ipfix::wire::{
1516
self, FlowRecord, IPFIX_VERSION, Set, decode_data_record, format_mac, parse_message,
1617
};
@@ -45,6 +46,7 @@ pub fn collect_ipfix(
4546

4647
let mut info_traffic_msg = InfoTraffic::default();
4748
let mut templates = TemplateCache::new();
49+
let mut totals = TotalsCache::new(Instant::now());
4850
let mut buf = vec![0u8; RECV_BUF_LEN];
4951
let mut first_packet_ticks: Option<Instant> = None;
5052

@@ -78,6 +80,7 @@ pub fn collect_ipfix(
7880
&buf[..len],
7981
peer,
8082
&mut templates,
83+
&mut totals,
8184
&mut info_traffic_msg,
8285
ip_blacklist,
8386
&mut resolutions_state,
@@ -103,10 +106,12 @@ fn current_timestamp() -> Timestamp {
103106
Timestamp::new(now.as_secs() as i64, i64::from(now.subsec_micros()))
104107
}
105108

109+
#[allow(clippy::too_many_arguments)]
106110
fn process_datagram(
107111
bytes: &[u8],
108112
peer: SocketAddr,
109113
templates: &mut TemplateCache,
114+
totals: &mut TotalsCache,
110115
info_traffic_msg: &mut InfoTraffic,
111116
ip_blacklist: &IpBlacklist,
112117
resolutions_state: &mut AddressesResolutionState,
@@ -119,7 +124,7 @@ fn process_datagram(
119124
return;
120125
}
121126

122-
let exporter_addresses = exporter_as_addresses(peer.ip());
127+
let now = Instant::now();
123128

124129
// First pass: register all templates so later data sets in the same
125130
// datagram can reference them.
@@ -165,7 +170,10 @@ fn process_datagram(
165170
remaining = rest;
166171
ingest_flow_record(
167172
&record,
168-
&exporter_addresses,
173+
peer,
174+
message.header.observation_domain_id,
175+
totals,
176+
now,
169177
info_traffic_msg,
170178
ip_blacklist,
171179
resolutions_state,
@@ -191,24 +199,30 @@ fn record_fits(template: &[wire::FieldSpec], remaining: &[u8]) -> bool {
191199
remaining.len() >= needed && needed > 0
192200
}
193201

202+
#[allow(clippy::too_many_arguments)]
194203
fn ingest_flow_record(
195204
record: &FlowRecord,
196-
exporter_addresses: &[Address],
205+
peer: SocketAddr,
206+
observation_domain_id: u32,
207+
totals: &mut TotalsCache,
208+
now: Instant,
197209
info_traffic_msg: &mut InfoTraffic,
198210
ip_blacklist: &IpBlacklist,
199211
resolutions_state: &mut AddressesResolutionState,
200212
) {
201213
let Some(key) = build_key(record) else {
202214
return;
203215
};
204-
// A record with neither counter carries nothing to account for — an
205-
// exporter whose counters we can't read (see the total-counter note in
206-
// `wire.rs`) would otherwise contribute a phantom packet per record.
207-
if record.bytes == 0 && record.packets == 0 {
216+
217+
let (exchanged_bytes, exchanged_packets) =
218+
resolve_counters(record, peer, observation_domain_id, &key, totals, now);
219+
// A record that resolved to nothing carries nothing to account for — an
220+
// exporter whose counters we can't read at all would otherwise contribute a
221+
// phantom flow per record.
222+
if exchanged_bytes == 0 && exchanged_packets == 0 {
208223
return;
209224
}
210-
let exchanged_bytes = record.bytes;
211-
let exchanged_packets = record.packets;
225+
212226
let mac_addresses = (
213227
record.src_mac.map(format_mac),
214228
record.dst_mac.map(format_mac),
@@ -218,7 +232,7 @@ fn ingest_flow_record(
218232
let (traffic_direction, service) = modify_or_insert_in_map(
219233
info_traffic_msg,
220234
&key,
221-
exporter_addresses,
235+
NO_INTERFACE_ADDRESSES,
222236
mac_addresses,
223237
None,
224238
ArpType::default(),
@@ -233,14 +247,51 @@ fn ingest_flow_record(
233247
info_traffic_msg,
234248
resolutions_state,
235249
&key,
236-
exporter_addresses,
250+
NO_INTERFACE_ADDRESSES,
237251
exchanged_bytes,
238252
exchanged_packets,
239253
traffic_direction,
240254
service,
241255
);
242256
}
243257

258+
/// Work out how much traffic this record actually adds.
259+
///
260+
/// Delta counters are already increments, so they're used as they stand.
261+
/// Cumulative counters have to be differenced against the flow's previous
262+
/// report. The totals are handed to the cache either way, so that a template
263+
/// carrying both kinds keeps the baseline current for the records that need it.
264+
fn resolve_counters(
265+
record: &FlowRecord,
266+
peer: SocketAddr,
267+
observation_domain_id: u32,
268+
key: &AddressPortPair,
269+
totals: &mut TotalsCache,
270+
now: Instant,
271+
) -> (u128, u128) {
272+
let (bytes_from_totals, packets_from_totals) = totals.delta(
273+
peer,
274+
observation_domain_id,
275+
key,
276+
record.bytes_total,
277+
record.packets_total,
278+
now,
279+
);
280+
281+
let bytes = if record.bytes > 0 {
282+
record.bytes
283+
} else {
284+
bytes_from_totals
285+
};
286+
let packets = if record.packets > 0 {
287+
record.packets
288+
} else {
289+
packets_from_totals
290+
};
291+
292+
(bytes, packets)
293+
}
294+
244295
fn build_key(record: &FlowRecord) -> Option<AddressPortPair> {
245296
let src = record.src_ip?;
246297
let dst = record.dst_ip?;
@@ -267,29 +318,21 @@ fn build_key(record: &FlowRecord) -> Option<AddressPortPair> {
267318
})
268319
}
269320

270-
/// Build a `[Address]` slice carrying just the exporter's IP, so host
271-
/// classification treats the exporter as the local anchor. Flow direction
272-
/// itself comes from IE 61 when the exporter sends it; this is only the
273-
/// fallback for exporters that report `undefined`.
274-
fn exporter_as_addresses(peer: IpAddr) -> Vec<Address> {
275-
if peer.is_loopback() || peer.is_unspecified() {
276-
return vec![];
277-
}
278-
279-
vec![Address {
280-
addr: peer,
281-
netmask: None,
282-
broadcast_addr: None,
283-
dst_addr: None,
284-
}]
285-
}
321+
/// Flows are observed somewhere else entirely, so there is no local interface
322+
/// to classify them against — the exporter's own IP is no help either, since a
323+
/// router exports flows between hosts that are both remote to it.
324+
///
325+
/// Passing no addresses is what PCAP import does, and it makes the downstream
326+
/// classifiers fall back to their bogon heuristic. Flow direction proper comes
327+
/// from IE 61 whenever the exporter sends it, which overrides the heuristic.
328+
const NO_INTERFACE_ADDRESSES: &[Address] = &[];
286329

287330
#[cfg(test)]
288331
mod tests {
289332
use super::*;
290333
use crate::networking::types::data_representation::DataRepr;
291334
use crate::networking::types::traffic_direction::TrafficDirection;
292-
use std::net::{Ipv4Addr, Ipv6Addr};
335+
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
293336

294337
/// The field specifiers `sniffnet-agent` puts in its templates, after the
295338
/// two address fields that differ between the IPv4 and IPv6 variants.
@@ -360,18 +403,30 @@ mod tests {
360403
}
361404

362405
fn run(bytes: &[u8]) -> (InfoTraffic, AddressesResolutionState) {
406+
let (info, resolutions, _) = run_all(&[bytes]);
407+
(info, resolutions)
408+
}
409+
410+
/// Feed a sequence of datagrams to one collector, so state that spans
411+
/// datagrams (templates, counter baselines) behaves as it would live.
412+
fn run_all(datagrams: &[&[u8]]) -> (InfoTraffic, AddressesResolutionState, TotalsCache) {
413+
let now = Instant::now();
363414
let mut templates = TemplateCache::new();
415+
let mut totals = TotalsCache::new(now);
364416
let mut info = InfoTraffic::default();
365417
let mut resolutions = AddressesResolutionState::new_detached();
366-
process_datagram(
367-
bytes,
368-
"203.0.113.9:4739".parse().unwrap(),
369-
&mut templates,
370-
&mut info,
371-
&IpBlacklist::default(),
372-
&mut resolutions,
373-
);
374-
(info, resolutions)
418+
for bytes in datagrams {
419+
process_datagram(
420+
bytes,
421+
"203.0.113.9:4739".parse().unwrap(),
422+
&mut templates,
423+
&mut totals,
424+
&mut info,
425+
&IpBlacklist::default(),
426+
&mut resolutions,
427+
);
428+
}
429+
(info, resolutions, totals)
375430
}
376431

377432
/// A template set plus a one-record data set, shaped exactly as the agent
@@ -452,6 +507,121 @@ mod tests {
452507
assert_eq!(info.tot_data_info.tot_data(DataRepr::Packets), 0);
453508
}
454509

510+
/// A template in the shape exporters that only report cumulative counters
511+
/// use: no delta IEs, no flowDirection.
512+
const TOTALS_FIELDS: [(u16, u16); 7] = [
513+
(8, 4),
514+
(12, 4),
515+
(7, 2),
516+
(11, 2),
517+
(4, 1),
518+
(85, 8), // octetTotalCount
519+
(86, 8), // packetTotalCount
520+
];
521+
522+
fn totals_record(bytes: u64, packets: u64) -> Vec<u8> {
523+
let mut r = Ipv4Addr::new(10, 0, 0, 1).octets().to_vec();
524+
r.extend_from_slice(&Ipv4Addr::new(8, 8, 8, 8).octets());
525+
r.extend_from_slice(&443u16.to_be_bytes());
526+
r.extend_from_slice(&50_000u16.to_be_bytes());
527+
r.push(6); // TCP
528+
r.extend_from_slice(&bytes.to_be_bytes());
529+
r.extend_from_slice(&packets.to_be_bytes());
530+
r
531+
}
532+
533+
fn totals_key() -> AddressPortPair {
534+
AddressPortPair {
535+
source: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
536+
sport: Some(443),
537+
dest: IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
538+
dport: Some(50_000),
539+
protocol: Protocol::TCP,
540+
}
541+
}
542+
543+
#[test]
544+
fn a_lone_totals_record_is_accounted_in_full() {
545+
// The single-record-per-flow-at-expiry case: the total is the flow.
546+
let bytes = datagram(&[
547+
template_set(300, &TOTALS_FIELDS),
548+
set(300, &totals_record(1500, 10)),
549+
]);
550+
let (info, _, _) = run_all(&[&bytes]);
551+
552+
let entry = info.map.get(&totals_key()).expect("flow present");
553+
assert_eq!(entry.transmitted_bytes, 1500);
554+
assert_eq!(entry.transmitted_packets, 10);
555+
}
556+
557+
#[test]
558+
fn repeated_totals_are_differenced_not_re_added() {
559+
let first = datagram(&[
560+
template_set(300, &TOTALS_FIELDS),
561+
set(300, &totals_record(1500, 10)),
562+
]);
563+
let grown = datagram(&[set(300, &totals_record(4000, 25))]);
564+
let unchanged = datagram(&[set(300, &totals_record(4000, 25))]);
565+
let (info, _, _) = run_all(&[&first, &grown, &unchanged]);
566+
567+
// 1500 + 2500 + 0 — not 1500 + 4000 + 4000.
568+
let entry = info.map.get(&totals_key()).expect("flow present");
569+
assert_eq!(entry.transmitted_bytes, 4000);
570+
assert_eq!(entry.transmitted_packets, 25);
571+
assert_eq!(info.tot_data_info.tot_data(DataRepr::Bytes), 4000);
572+
assert_eq!(info.tot_data_info.tot_data(DataRepr::Packets), 25);
573+
}
574+
575+
#[test]
576+
fn deltas_are_preferred_when_a_template_carries_both() {
577+
// layer2OctetDeltaCount + packetDeltaCount alongside the totals: the
578+
// deltas are already increments, so they're what gets accounted.
579+
let fields = [
580+
(8, 4),
581+
(12, 4),
582+
(7, 2),
583+
(11, 2),
584+
(4, 1),
585+
(352, 8), // layer2OctetDeltaCount
586+
(2, 8), // packetDeltaCount
587+
(85, 8), // octetTotalCount
588+
(86, 8), // packetTotalCount
589+
];
590+
let record =
591+
|delta_bytes: u64, delta_packets: u64, total_bytes: u64, total_packets: u64| {
592+
let mut r = totals_record(delta_bytes, delta_packets);
593+
r.extend_from_slice(&total_bytes.to_be_bytes());
594+
r.extend_from_slice(&total_packets.to_be_bytes());
595+
r
596+
};
597+
598+
let first = datagram(&[
599+
template_set(300, &fields),
600+
set(300, &record(600, 4, 600, 4)),
601+
]);
602+
let second = datagram(&[set(300, &record(900, 6, 1500, 10))]);
603+
let (info, _, _) = run_all(&[&first, &second]);
604+
605+
let entry = info.map.get(&totals_key()).expect("flow present");
606+
assert_eq!(entry.transmitted_bytes, 1500);
607+
assert_eq!(entry.transmitted_packets, 10);
608+
}
609+
610+
#[test]
611+
fn direction_falls_back_to_the_bogon_heuristic_without_ie_61() {
612+
// No flowDirection in this template and no interface addresses to
613+
// compare against, so the private source has to carry the decision —
614+
// the same way PCAP import classifies it.
615+
let bytes = datagram(&[
616+
template_set(300, &TOTALS_FIELDS),
617+
set(300, &totals_record(1500, 10)),
618+
]);
619+
let (info, _, _) = run_all(&[&bytes]);
620+
621+
let entry = info.map.get(&totals_key()).expect("flow present");
622+
assert_eq!(entry.traffic_direction, TrafficDirection::Outgoing);
623+
}
624+
455625
#[test]
456626
fn data_set_without_a_known_template_is_skipped() {
457627
// Data referencing template 256 before any template set has arrived.

src/networking/ipfix/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
88
pub mod collect;
99
pub mod templates;
10+
pub mod totals;
1011
pub mod wire;
1112

1213
use serde::{Deserialize, Serialize};

0 commit comments

Comments
 (0)