Runtime Ownership hardened

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-30 08:38:03 +03:00
parent 1bb6b0bdda
commit 281f63f940
91 changed files with 3972 additions and 1239 deletions
+79 -36
View File
@@ -1,17 +1,21 @@
//! Per-IP forensic buckets for scanner and handshake failure observation.
use std::collections::hash_map::RandomState;
use std::collections::{BTreeMap, HashMap};
use std::hash::{BuildHasher, Hash, Hasher};
use std::net::IpAddr;
use std::time::{Duration, Instant};
use parking_lot::Mutex;
const CLEANUP_INTERVAL: Duration = Duration::from_secs(30);
const MAX_BEOBACHTEN_ENTRIES: usize = 65_536;
const BEOBACHTEN_SHARDS: usize = 64;
const BEOBACHTEN_ENTRIES_PER_SHARD: usize = 1024;
#[derive(Default)]
struct BeobachtenInner {
entries: HashMap<(String, IpAddr), BeobachtenEntry>,
struct BeobachtenShard {
classes: HashMap<String, HashMap<IpAddr, BeobachtenEntry>>,
entries: usize,
last_cleanup: Option<Instant>,
}
@@ -23,7 +27,8 @@ struct BeobachtenEntry {
/// In-memory, TTL-scoped per-IP counters keyed by source class.
pub struct BeobachtenStore {
inner: Mutex<BeobachtenInner>,
shards: Vec<Mutex<BeobachtenShard>>,
hash_builder: RandomState,
}
impl Default for BeobachtenStore {
@@ -35,7 +40,10 @@ impl Default for BeobachtenStore {
impl BeobachtenStore {
pub fn new() -> Self {
Self {
inner: Mutex::new(BeobachtenInner::default()),
shards: (0..BEOBACHTEN_SHARDS)
.map(|_| Mutex::new(BeobachtenShard::default()))
.collect(),
hash_builder: RandomState::new(),
}
}
@@ -45,27 +53,31 @@ impl BeobachtenStore {
}
let now = Instant::now();
let mut guard = self.inner.lock();
Self::cleanup_if_needed(&mut guard, now, ttl);
let shard_index = self.shard_index(class, ip);
let mut shard = self.shards[shard_index].lock();
Self::cleanup_if_needed(&mut shard, now, ttl);
let key = (class.to_string(), ip);
if let Some(entry) = guard.entries.get_mut(&key) {
if let Some(entry) = shard
.classes
.get_mut(class)
.and_then(|entries| entries.get_mut(&ip))
{
entry.tries = entry.tries.saturating_add(1);
entry.last_seen = now;
return;
}
if guard.entries.len() >= MAX_BEOBACHTEN_ENTRIES {
if shard.entries >= BEOBACHTEN_ENTRIES_PER_SHARD {
return;
}
guard.entries.insert(
key,
shard.classes.entry(class.to_string()).or_default().insert(
ip,
BeobachtenEntry {
tries: 1,
last_seen: now,
},
);
shard.entries = shard.entries.saturating_add(1);
}
pub fn snapshot_text(&self, ttl: Duration) -> String {
@@ -74,21 +86,15 @@ impl BeobachtenStore {
}
let now = Instant::now();
let entries = {
let mut guard = self.inner.lock();
Self::cleanup(&mut guard, now, ttl);
guard.last_cleanup = Some(now);
guard
.entries
.iter()
.map(|((class, ip), entry)| (class.clone(), *ip, entry.tries))
.collect::<Vec<_>>()
};
let mut grouped = BTreeMap::<String, Vec<(IpAddr, u64)>>::new();
for (class, ip, tries) in entries {
grouped.entry(class).or_default().push((ip, tries));
for shard in &self.shards {
let mut shard = shard.lock();
Self::cleanup(&mut shard, now, ttl);
shard.last_cleanup = Some(now);
for (class, entries) in &shard.classes {
let output = grouped.entry(class.clone()).or_default();
output.extend(entries.iter().map(|(ip, entry)| (*ip, entry.tries)));
}
}
if grouped.is_empty() {
@@ -111,24 +117,61 @@ impl BeobachtenStore {
out.push_str(&format!("{ip}-{tries}\n"));
}
}
out
}
fn cleanup_if_needed(inner: &mut BeobachtenInner, now: Instant, ttl: Duration) {
let should_cleanup = match inner.last_cleanup {
fn shard_index(&self, class: &str, ip: IpAddr) -> usize {
let mut hasher = self.hash_builder.build_hasher();
class.hash(&mut hasher);
ip.hash(&mut hasher);
(hasher.finish() as usize) % BEOBACHTEN_SHARDS
}
fn cleanup_if_needed(shard: &mut BeobachtenShard, now: Instant, ttl: Duration) {
let should_cleanup = match shard.last_cleanup {
Some(last) => now.saturating_duration_since(last) >= CLEANUP_INTERVAL,
None => true,
};
if should_cleanup {
Self::cleanup(inner, now, ttl);
inner.last_cleanup = Some(now);
Self::cleanup(shard, now, ttl);
shard.last_cleanup = Some(now);
}
}
fn cleanup(inner: &mut BeobachtenInner, now: Instant, ttl: Duration) {
inner
.entries
.retain(|_, entry| now.saturating_duration_since(entry.last_seen) <= ttl);
fn cleanup(shard: &mut BeobachtenShard, now: Instant, ttl: Duration) {
for entries in shard.classes.values_mut() {
entries.retain(|_, entry| now.saturating_duration_since(entry.last_seen) <= ttl);
}
shard.classes.retain(|_, entries| !entries.is_empty());
shard.entries = shard.classes.values().map(HashMap::len).sum();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_shard_never_exceeds_its_allocation_bound() {
let store = BeobachtenStore::new();
let ttl = Duration::from_secs(60);
let target_shard = 0;
let mut inserted = 0usize;
for suffix in 0..u32::MAX {
let ip = IpAddr::V4(std::net::Ipv4Addr::from(suffix));
if store.shard_index("scanner", ip) != target_shard {
continue;
}
store.record("scanner", ip, ttl);
inserted = inserted.saturating_add(1);
if inserted > BEOBACHTEN_ENTRIES_PER_SHARD + 64 {
break;
}
}
assert_eq!(
store.shards[target_shard].lock().entries,
BEOBACHTEN_ENTRIES_PER_SHARD
);
}
}
+80 -5
View File
@@ -77,11 +77,45 @@ impl Stats {
if !self.telemetry_me_allows_normal() {
return;
}
let entry = self
.me_handshake_error_codes
.entry(code)
.or_insert_with(|| AtomicU64::new(0));
entry.fetch_add(1, Ordering::Relaxed);
if let Some(entry) = self.me_handshake_error_codes.get(&code) {
entry.fetch_add(1, Ordering::Relaxed);
return;
}
let mut slots = self
.me_handshake_error_code_slots
.load(Ordering::Acquire);
loop {
if slots >= ME_HANDSHAKE_ERROR_CODE_MAX {
if let Some(entry) = self.me_handshake_error_codes.get(&code) {
entry.fetch_add(1, Ordering::Relaxed);
} else {
self.me_handshake_error_code_overflow_total
.fetch_add(1, Ordering::Relaxed);
}
return;
}
match self.me_handshake_error_code_slots.compare_exchange_weak(
slots,
slots + 1,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => break,
Err(observed) => slots = observed,
}
}
match self.me_handshake_error_codes.entry(code) {
dashmap::mapref::entry::Entry::Occupied(entry) => {
self.me_handshake_error_code_slots
.fetch_sub(1, Ordering::AcqRel);
entry.get().fetch_add(1, Ordering::Relaxed);
}
dashmap::mapref::entry::Entry::Vacant(entry) => {
entry.insert(AtomicU64::new(1));
}
}
}
pub fn increment_me_reader_eof_total(&self) {
if self.telemetry_me_allows_normal() {
@@ -440,3 +474,44 @@ impl Stats {
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
#[test]
fn handshake_error_code_cardinality_is_bounded_under_concurrency() {
const WORKERS: usize = 8;
const CODES_PER_WORKER: usize = 128;
let stats = Arc::new(Stats::new());
std::thread::scope(|scope| {
for worker in 0..WORKERS {
let stats = stats.clone();
scope.spawn(move || {
for code in 0..CODES_PER_WORKER {
stats.increment_me_handshake_error_code(
(worker * CODES_PER_WORKER + code) as i32,
);
}
});
}
});
let counts = stats.get_me_handshake_error_code_counts();
assert!(counts.len() <= ME_HANDSHAKE_ERROR_CODE_MAX);
assert_eq!(
counts.iter().map(|(_, total)| *total).sum::<u64>()
+ stats.get_me_handshake_error_code_overflow_total(),
(WORKERS * CODES_PER_WORKER) as u64
);
assert_eq!(
stats
.me_handshake_error_code_slots
.load(Ordering::Acquire),
counts.len()
);
}
}
+4
View File
@@ -10,6 +10,10 @@ impl Stats {
out.sort_by_key(|(code, _)| *code);
out
}
pub fn get_me_handshake_error_code_overflow_total(&self) -> u64 {
self.me_handshake_error_code_overflow_total
.load(Ordering::Relaxed)
}
pub fn get_me_route_drop_no_conn(&self) -> u64 {
self.me_route_drop_no_conn.load(Ordering::Relaxed)
}
+5 -1
View File
@@ -18,7 +18,7 @@ mod writer_counters;
use dashmap::DashMap;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
use std::time::Instant;
pub(crate) use self::quota_store::QuotaStore;
@@ -28,6 +28,8 @@ use self::telemetry::TelemetryPolicy;
pub use self::tls_fingerprints::TlsFingerprintSnapshotRow;
use crate::config::MeWriterPickMode;
const ME_HANDSHAKE_ERROR_CODE_MAX: usize = 64;
#[derive(Clone, Copy)]
enum RouteConnectionGauge {
Direct,
@@ -219,6 +221,8 @@ pub struct Stats {
me_floor_swap_idle_total: AtomicU64,
me_floor_swap_idle_failed_total: AtomicU64,
me_handshake_error_codes: DashMap<i32, AtomicU64>,
me_handshake_error_code_slots: AtomicUsize,
me_handshake_error_code_overflow_total: AtomicU64,
me_route_drop_no_conn: AtomicU64,
me_route_drop_channel_closed: AtomicU64,
me_route_drop_queue_full: AtomicU64,
+4
View File
@@ -56,6 +56,10 @@ impl QuotaStore {
}
}
pub(crate) fn remove(&self, user: &str) {
self.users.remove(user);
}
pub(crate) fn snapshot(&self) -> HashMap<String, UserQuotaSnapshot> {
let mut out = HashMap::new();
for entry in self.users.iter() {