// SPDX-FileCopyrightText: 2026 Dilluti0n // SPDX-License-Identifier: GPL-3.0-or-later //! Linear probing hash table for ip-hop cache //! //! On inbound SYN/ACK (src port 443) from a server, infer the hop //! count from the observed TTL via `crate::pkt::fake::infer_hops` and //! store it with [`HopTab::put`]. Later, when sending a fake //! ClientHello, look up the hop count by destination IP //! [`HopTab::find_hop`] to automatically choose an appropriate TTL. //! //! One could consider keeping a global variable (like `current_hop`), //! loading it when a SYN/ACK arrives, and reading it when needed. The //! problem is that, before the ClientHello is sent after that SYN/ACK //! (i.e., in between), a SYN/ACK from a different server may //! arrive. To handle this, the (ip, hop) pair must be stored in an //! appropriate data structure and looked up later. //! //! [`HopTab`] has capacity [`CAP`], which means that if at least //! [`CAP`] SYN/ACKs arrive from distinct servers before the first //! ClientHello is sent, [`HopLookupError::NotFound`] will inevitably //! occur. (Given the size of [`CAP`], this is extremely unlikely.) //! Also, after an entry is inserted, if [`HopTab::update`] runs at //! least [`HopTab::STALE_AGE`] times, the entry is marked stale and //! becomes evictable, so once the number of updates reaches //! [`HopTab::STALE_AGE`] or more, there is a chance that //! [`HopLookupError::NotFound`] occurs. Other than these cases, it //! will not occur. use std::fmt; use std::net::IpAddr; use std::sync::{Mutex, OnceLock}; use std::net::{Ipv4Addr, Ipv6Addr}; /// Size of [`HopTab`] const CAP: usize = 1 << 7; // 128 /// 128-bit (IPv6-shaped) unified IP key for [`HopTab`] lookups (IPv4 /// stored as ::ffff:a.b.c.d). #[repr(C)] #[derive(Clone, Copy, PartialEq, Eq)] struct HopKey { hi: u64, lo: u64 } impl HopKey { const ZERO: Self = Self { hi: 0, lo: 0 }; #[inline] fn from_ipaddr(ip: IpAddr) -> Self { match ip { IpAddr::V4(v4) => { // ::ffff:a.b.c.d (IPv4-mapped IPv6) let v4u = u32::from(v4) as u64; Self { hi: 0, lo: (0xFFFFu64 << 32) | v4u } } IpAddr::V6(v6) => { let b = v6.octets(); let hi = u64::from_be_bytes(b[0..8].try_into().unwrap()); let lo = u64::from_be_bytes(b[8..16].try_into().unwrap()); Self { hi, lo } } } } #[inline] fn to_ipaddr(self) -> IpAddr { // ::ffff:a.b.c.d (IPv4-mapped IPv6) if self.hi == 0 && (self.lo >> 32) == 0x0000_FFFF { let v4 = (self.lo & 0xFFFF_FFFF) as u32; return IpAddr::V4(Ipv4Addr::from(v4)); } let mut b = [0u8; 16]; b[0..8].copy_from_slice(&self.hi.to_be_bytes()); b[8..16].copy_from_slice(&self.lo.to_be_bytes()); IpAddr::V6(Ipv6Addr::from(b)) } } #[derive(Clone, Copy)] struct HopTabEntry { key: HopKey, /// [RESERVED(32) | TS(16) | HOP(8) | STATE(8)] /// * TS: timestamp snapshot (see [`HopTab::now`]) /// * HOP: stored hop count /// * STATE: [`Self::ST_OCCUPIED`], [`Self::ST_TOUCHED`] meta: u64, } impl HopTabEntry { /// For internal use; Do not use it globally const ST_EMPTY: u8 = 0; const ST_OCCUPIED: u8 = 1 << 0; /// Entry has been touched (i.e., consumed by [`HopTab::find_hop`] /// at least once). /// /// Note: "touched" does not mean "recently used, keep it". It /// means the entry already served its purpose (consumed for Fake /// ClientHello TTL), so it is more eligible for eviction under /// pressure than a fresh, untouched entry. const ST_TOUCHED: u8 = 1 << 1; const EMPTY: Self = Self { key: HopKey::ZERO, meta: Self::ST_EMPTY as u64}; const S_STATE: usize = 0; const S_HOP: usize = 8; const S_TS: usize = 16; #[inline] fn key(&self) -> HopKey { self.key } #[inline] fn new(key: HopKey, ts: u16, hop: u8) -> Self { Self { key: key, meta: ((ts as u64) << Self::S_TS) | ((hop as u64) << Self::S_HOP) | ((Self::ST_OCCUPIED as u64) << Self::S_STATE) } } #[inline] fn hop(&self) -> u8 { (self.meta >> Self::S_HOP) as u8 } #[inline] fn state(&self) -> u8 { (self.meta >> Self::S_STATE) as u8 } #[inline] fn has(&self, mask: u8) -> bool { (self.state() & mask) == mask } #[inline] fn touch(&mut self) { self.meta |= Self::ST_TOUCHED as u64; } #[inline] fn ts(&self) -> u16 { (self.meta >> Self::S_TS) as u16 } } impl fmt::Debug for HopTabEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let ip = self.key.to_ipaddr(); let state = self.state(); let hop = self.hop(); let ts = self.ts(); write!( f, "HopTabEntry{{ ip={}, state=0x{:02x}, hop={}, ts={}, meta=0x{:016x} }}", ip, state, hop, ts, self.meta ) } } #[derive(Debug, Clone)] pub enum HopLookupError { NotFound { ip: IpAddr }, } impl fmt::Display for HopLookupError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { HopLookupError::NotFound { ip } => write!(f, "hop not found for {ip}"), } } } impl std::error::Error for HopLookupError {} pub type HopResult = std::result::Result; struct HopTab { entries: Box<[HopTabEntry; CAP]>, /// Logical tick counter. /// /// Increments on every successful [`Self::update`] /// (put/overwrite) and wraps via [`u16::wrapping_add`]. It is /// ensured that at least [`CAP`] - [`Self::STALE_AGE`] entries /// evictable. (i.e. [`HopTab`] not become corrupted.) now: u16, } /// Non-cryptographic hash using SplitMix64-style finalizer #[inline] fn hash(key: HopKey) -> usize { let mut x = key.hi ^ key.lo.rotate_left(13); x ^= x >> 30; x = x.wrapping_mul(0xbf58476d1ce4e5b9); x ^= x >> 27; x = x.wrapping_mul(0x94d049bb133111eb); x ^= x >> 31; x as usize } trait HashIdx { /// `CAP` must be a power of two (we index via `hash & (CAP-1)`). fn to_idx(self) -> usize; } impl HashIdx for usize { #[inline] fn to_idx(self) -> usize { self & (CAP - 1) } } #[derive(PartialEq, PartialOrd, Clone, Copy)] enum EvictPriority { /// Occupied && fresh && untouched None = 0, /// Already consumed Touched = 1, Stale = 2, Empty = 3, /// Same key occers MustUpdate = 4, } impl HopTab { const ASSERT_CAP_POW2: () = { assert!(CAP.is_power_of_two()); }; /// To avoid the edge case where all entries become non-stale, /// [`Self::STALE_AGE`] must be smaller than [`CAP`]. const STALE_AGE: usize = CAP >> 1; // 64 fn new() -> Self { _ = Self::ASSERT_CAP_POW2; Self { entries: Box::new([HopTabEntry::EMPTY; CAP]), now: 0, } } #[inline] fn age(&self, entry: &HopTabEntry) -> u16 { // Since we use u16 with wrapping_sub, the age calculation remains // correct even when `self.now` overflows and wraps around to zero. // This holds true as long as the temporal distance between // `entry.ts()` and `self.now` does not exceed 2^15 (32,768). // Given that STALE_AGE (64) << 2^15, the "stale" judgment is // always mathematically sound. self.now.wrapping_sub(entry.ts()) } #[inline] fn is_stale(&self, entry: &HopTabEntry) -> bool { self.age(entry) >= Self::STALE_AGE as u16 } #[inline] fn update(&mut self, idx: usize, new: HopTabEntry) { self.entries[idx] = new; self.now = self.now.wrapping_add(1); } #[inline] fn evict_priority(&self, entry: &HopTabEntry) -> EvictPriority { if !entry.has(HopTabEntry::ST_OCCUPIED) { EvictPriority::Empty } else if self.is_stale(entry) { EvictPriority::Stale } else if entry.has(HopTabEntry::ST_TOUCHED) { EvictPriority::Touched } else { EvictPriority::None } } fn put(&mut self, ip: IpAddr, hop: u8) { let key = HopKey::from_ipaddr(ip); let entry = HopTabEntry::new(key, self.now, hop); let start = hash(key).to_idx::(); let mut victim = (0, EvictPriority::None); // (idx, priority) // Key must be unique in the table for step in 0..CAP { let idx = (start + step).to_idx::(); let e = self.entries[idx]; // Hit; must update same key (hop could be changed) if e.key() == key && e.has(HopTabEntry::ST_OCCUPIED) { victim = (idx, EvictPriority::MustUpdate); #[cfg(debug_assertions)] crate::debug!("HopTab::put: hit {}; {:#?}", victim.0, entry); break; } let prio = self.evict_priority(&e); if prio > victim.1 { victim = (idx, prio); if prio == EvictPriority::Empty { #[cfg(debug_assertions)] crate::debug!("HopTab::put: hit empty {}; {:#?}", victim.0, entry); break; // linear probing; there is no key here } } } if victim.1 > EvictPriority::None { self.update(victim.0, entry); #[cfg(debug_assertions)] crate::debug!("HopTab::put: update {} to {:#?}", victim.0, entry); } else { crate::error!("HopTab::put: update fail: corrupted; {:#?}", entry); } } fn find_hop(&mut self, ip: IpAddr) -> HopResult { let key = HopKey::from_ipaddr(ip); let start = hash(key).to_idx::(); for step in 0..CAP { let idx = (start + step).to_idx::(); let e = self.entries[idx]; if !e.has(HopTabEntry::ST_OCCUPIED) { break; // linear probing; there is no key here } if e.key() == key { self.entries[idx].touch(); #[cfg(debug_assertions)] crate::debug!("HopTab::find_hop: found {idx}; {:#?}", e); return Ok(e.hop()); } } Err(HopLookupError::NotFound { ip }) } } static H_TAB: OnceLock>> = OnceLock::new(); #[inline] fn htab() -> std::sync::MutexGuard<'static, HopTab> { H_TAB.get_or_init(|| Mutex::new(HopTab::new())) .lock() .unwrap() } pub fn put(ip: IpAddr, hop: u8) { htab().put(ip, hop) } pub fn find(ip: IpAddr) -> HopResult { htab().find_hop(ip) } // // below are test/bench codes // #[cfg(test)] mod tests { use super::*; use std::fs::File; use std::io::Read; use std::net::{IpAddr, Ipv4Addr}; #[test] fn test_hop_key_conversion() { let ip: IpAddr = "1.2.3.4".parse().unwrap(); let key = HopKey::from_ipaddr(ip); assert_eq!(key.to_ipaddr(), ip); } #[test] fn test_basic_flow() { let ip: IpAddr = "1.1.1.1".parse().unwrap(); put(ip, 12); let result = find(ip).expect("cannot find {ip}"); assert_eq!(result, 12); } #[test] fn test_not_found() { let ip: IpAddr = "8.8.8.8".parse().unwrap(); let result = find(ip); assert!(result.is_err()); } fn u32_to_ipaddr(i: u32) -> IpAddr { IpAddr::V4(Ipv4Addr::from(i)) } #[test] fn test_full_table() { let mut tab = HopTab::::new(); for i in 0..CAP { tab.put(u32_to_ipaddr(i as u32), i as u8); } for i in 0..CAP { assert_eq!(tab.find_hop(u32_to_ipaddr(i as u32)).unwrap(), i as u8); } } fn get_random(size: usize) -> Vec { const RAND: &'static str = "/dev/urandom"; let mut f = File::open(RAND).unwrap(); let mut buf = vec![0u8; size]; f.read_exact(&mut buf).unwrap(); buf } const ITERATIONS: usize = 1 << 19; fn get_random_bulk() -> Vec { get_random(ITERATIONS * 5) } fn get_iphop(raw: &Vec, idx: usize) -> (IpAddr, u8) { let off = idx * 5; let ip_num = u32::from_ne_bytes(raw[off..off+4].try_into().unwrap()); (u32_to_ipaddr(ip_num), raw[off+4]) } #[test] fn test_hoptab_stress() { let mut tab = HopTab::::new(); let rand = get_random_bulk(); for i in 0..ITERATIONS { let (ip, hop) = get_iphop(&rand, i); tab.put(ip, hop); assert_eq!(tab.find_hop(ip).unwrap(), hop); } } #[test] fn test_random_cache_integrity() { let mut tab = HopTab::::new(); const SAFE_RANGE: usize = CAP - (CAP >> 1); let rand = get_random_bulk(); let mut recent_data = std::collections::VecDeque::with_capacity(SAFE_RANGE); for i in 0..ITERATIONS { let (ip, hop) = get_iphop(&rand, i); tab.put(ip, hop); if recent_data.len() == SAFE_RANGE { recent_data.pop_front(); } recent_data.push_back((ip, hop)); } for (ip, expected_hop) in recent_data { let actual_hop = tab.find_hop(ip).expect("In-flight data should not be evicted"); assert_eq!(actual_hop, expected_hop); } } #[test] fn test_age_overflow_handling() { let mut tab = HopTab::::new(); let ip1 = u32_to_ipaddr(1); let ip2 = u32_to_ipaddr(2); tab.now = u16::MAX; tab.put(ip1, 10); assert_eq!(tab.now, 0); tab.put(ip2, 20); assert_eq!(tab.now, 1); let entry1 = tab.entries[hash(HopKey::from_ipaddr(ip1)).to_idx::()]; assert_eq!(tab.age(&entry1), 2); assert!(!tab.is_stale(&entry1)); const DISTINCT: u32 = 3; let ip3 = u32_to_ipaddr(DISTINCT); // Since there is no lookup for ip1, it is not become // evictable by putting ip3. Use STALE_AGE - 1 because ip2 has // been putted already. for _ in 0..HopTab::::STALE_AGE-1 { tab.put(ip3, 3); } assert!(tab.is_stale(&entry1)); } } #[cfg(feature = "bench")] #[allow(unused_imports)] pub use bench_support::reset; #[cfg(feature = "bench")] #[allow(dead_code)] mod bench_support { use super::*; impl HopTabEntry { #[inline] fn clear(&mut self) { self.meta &= (!Self::ST_OCCUPIED << Self::S_STATE) as u64; } } impl HopTab { fn reset(&mut self) { for i in 0..CAP { self.entries[i].clear(); } self.now = 0; } } #[inline] pub fn reset() { htab().reset(); } }