44
55use async_channel:: Sender ;
66use pcap:: Address ;
7- use std:: net:: { IpAddr , SocketAddr , UdpSocket } ;
7+ use std:: net:: { SocketAddr , UdpSocket } ;
88use std:: time:: Instant ;
99use tokio:: sync:: broadcast:: Receiver ;
1010
1111use crate :: location;
1212use crate :: mmdb:: types:: mmdb_reader:: MmdbReaders ;
1313use crate :: networking:: ipfix:: templates:: TemplateCache ;
14+ use crate :: networking:: ipfix:: totals:: TotalsCache ;
1415use 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) ]
106110fn 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) ]
194203fn 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+
244295fn 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) ]
288331mod 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.
0 commit comments