use std::{
collections::{BTreeMap, HashMap},
net::SocketAddr,
};
use ts_packetfilter::IpProto;
pub(crate) const LRU_MAX: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct Tuple {
pub(crate) proto: IpProto,
pub(crate) src: SocketAddr,
pub(crate) dst: SocketAddr,
}
fn is_tracked(proto: IpProto) -> bool {
matches!(proto, IpProto::UDP | IpProto::SCTP)
}
#[derive(Debug, Default)]
pub(crate) struct FlowCache {
by_tuple: HashMap<Tuple, u64>,
by_recency: BTreeMap<u64, Tuple>,
next_stamp: u64,
}
impl FlowCache {
pub(crate) fn record_outbound(&mut self, proto: IpProto, src: SocketAddr, dst: SocketAddr) {
if !is_tracked(proto) {
return;
}
self.add(Tuple {
proto,
src: dst,
dst: src,
});
}
pub(crate) fn admits_inbound(
&mut self,
proto: IpProto,
src: SocketAddr,
dst: SocketAddr,
) -> bool {
if !is_tracked(proto) {
return false;
}
self.get(&Tuple { proto, src, dst })
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.by_tuple.len()
}
fn bump(&mut self) -> u64 {
let stamp = self.next_stamp;
self.next_stamp += 1;
stamp
}
fn add(&mut self, tuple: Tuple) {
let stamp = self.bump();
if let Some(previous) = self.by_tuple.insert(tuple, stamp) {
self.by_recency.remove(&previous);
}
self.by_recency.insert(stamp, tuple);
while self.by_tuple.len() > LRU_MAX {
let Some((_, evicted)) = self.by_recency.pop_first() else {
break;
};
self.by_tuple.remove(&evicted);
}
}
fn get(&mut self, tuple: &Tuple) -> bool {
let Some(&previous) = self.by_tuple.get(tuple) else {
return false;
};
let stamp = self.bump();
self.by_tuple.insert(*tuple, stamp);
self.by_recency.remove(&previous);
self.by_recency.insert(stamp, *tuple);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sa(s: &str) -> SocketAddr {
s.parse().expect("socket address")
}
#[test]
fn outbound_datagram_admits_only_its_own_reply() {
let mut flows = FlowCache::default();
let src = sa("100.64.0.1:41234");
let dst = sa("192.0.2.7:53");
flows.record_outbound(IpProto::UDP, src, dst);
assert!(
flows.admits_inbound(IpProto::UDP, dst, src),
"the reply to the recorded flow is admitted (Go's Accept, \"cached\")"
);
assert!(
!flows.admits_inbound(IpProto::UDP, src, dst),
"the outbound direction itself is not a recorded reply"
);
assert!(
!flows.admits_inbound(IpProto::UDP, sa("198.51.100.7:53"), src),
"a different source ADDRESS does not ride the entry"
);
assert!(
!flows.admits_inbound(IpProto::UDP, sa("192.0.2.7:5353"), src),
"a different source PORT does not ride the entry"
);
assert!(
!flows.admits_inbound(IpProto::UDP, dst, sa("100.64.0.1:41235")),
"a different destination port does not ride the entry"
);
assert!(
!flows.admits_inbound(IpProto::UDP, dst, sa("100.64.0.2:41234")),
"a different destination address does not ride the entry"
);
assert!(
!flows.admits_inbound(IpProto::SCTP, dst, src),
"the protocol is part of Go's tuple: SCTP does not ride a UDP entry"
);
}
#[test]
fn only_udp_and_sctp_are_tracked() {
let mut flows = FlowCache::default();
let src = sa("100.64.0.1:41234");
let dst = sa("192.0.2.7:443");
flows.record_outbound(IpProto::SCTP, src, dst);
assert!(
flows.admits_inbound(IpProto::SCTP, dst, src),
"SCTP is tracked, same as UDP (Go `case ipproto.UDP, ipproto.SCTP`)"
);
for proto in [
IpProto::TCP,
IpProto::ICMP,
IpProto::ICMPV6,
IpProto::TSMP,
IpProto::new(0),
] {
let before = flows.len();
flows.record_outbound(proto, src, dst);
assert_eq!(before, flows.len(), "{proto:?} must not be recorded");
assert!(
!flows.admits_inbound(proto, dst, src),
"{proto:?} is never admitted from the flow cache"
);
}
}
#[test]
fn the_cache_is_bounded_at_gos_lru_max() {
let mut flows = FlowCache::default();
let me = |port: u16| SocketAddr::from((std::net::Ipv4Addr::new(100, 64, 0, 1), port));
let dst = sa("192.0.2.7:53");
let port = |i: usize| u16::try_from(1024 + i).expect("port fits");
flows.record_outbound(IpProto::UDP, me(port(0)), dst);
for i in 1..(LRU_MAX * 10) {
flows.record_outbound(IpProto::UDP, me(port(i)), dst);
assert!(
flows.len() <= LRU_MAX,
"the cache never exceeds Go's lruMax ({LRU_MAX})"
);
}
assert_eq!(flows.len(), LRU_MAX, "and it fills to exactly that bound");
assert!(
!flows.admits_inbound(IpProto::UDP, dst, me(port(0))),
"the oldest flow was evicted, so its reply is back to needing a rule"
);
assert!(
flows.admits_inbound(IpProto::UDP, dst, me(port(LRU_MAX * 10 - 1))),
"the newest flow is still tracked"
);
}
#[test]
fn a_hit_refreshes_recency_so_a_busy_flow_survives_eviction() {
let mut flows = FlowCache::default();
let me = |port: u16| SocketAddr::from((std::net::Ipv4Addr::new(100, 64, 0, 1), port));
let dst = sa("192.0.2.7:53");
let port = |i: usize| u16::try_from(1024 + i).expect("port fits");
for i in 0..LRU_MAX {
flows.record_outbound(IpProto::UDP, me(port(i)), dst);
}
assert!(flows.admits_inbound(IpProto::UDP, dst, me(port(0))));
flows.record_outbound(IpProto::UDP, me(port(LRU_MAX)), dst);
assert_eq!(flows.len(), LRU_MAX);
assert!(
flows.admits_inbound(IpProto::UDP, dst, me(port(0))),
"the refreshed flow survived"
);
assert!(
!flows.admits_inbound(IpProto::UDP, dst, me(port(1))),
"the next-oldest flow was evicted instead"
);
}
}