use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use serde::Serialize;
#[derive(Debug, Default)]
pub struct EndpointStats {
ingress: AtomicU64,
egress: AtomicU64,
connections: AtomicU64,
ip: Mutex<Option<IpAddr>>,
}
impl EndpointStats {
#[inline]
pub fn add_ingress(&self, n: u64) {
self.ingress.fetch_add(n, Ordering::Relaxed);
}
#[inline]
pub fn add_egress(&self, n: u64) {
self.egress.fetch_add(n, Ordering::Relaxed);
}
#[inline]
pub fn add_connection(&self) {
self.connections.fetch_add(1, Ordering::Relaxed);
}
pub fn observe_ip(&self, addr: IpAddr) {
let mut slot = self.ip.lock().expect("stats mutex poisoned");
slot.get_or_insert(addr);
}
pub fn ingress(&self) -> u64 {
self.ingress.load(Ordering::Relaxed)
}
pub fn egress(&self) -> u64 {
self.egress.load(Ordering::Relaxed)
}
pub fn connections(&self) -> u64 {
self.connections.load(Ordering::Relaxed)
}
pub fn ip(&self) -> Option<IpAddr> {
*self.ip.lock().expect("stats mutex poisoned")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Endpoint {
pub domain: String,
pub ip_address: Option<IpAddr>,
pub ingress_bytes: u64,
pub egress_bytes: u64,
pub connections: u64,
pub matches_filter: bool,
}
#[derive(Debug, Default)]
pub struct Registry {
endpoints: Mutex<HashMap<String, Arc<EndpointStats>>>,
open_connections: AtomicU64,
next_conn_id: AtomicU64,
}
impl Registry {
pub fn new() -> Self {
Self::default()
}
pub fn endpoint(&self, host: &str) -> Arc<EndpointStats> {
let mut map = self.endpoints.lock().expect("registry mutex poisoned");
Arc::clone(map.entry(host.to_owned()).or_default())
}
pub fn next_conn_id(&self) -> u64 {
self.next_conn_id.fetch_add(1, Ordering::Relaxed) + 1
}
pub fn track_open(self: &Arc<Self>) -> OpenGuard {
self.open_connections.fetch_add(1, Ordering::SeqCst);
OpenGuard {
registry: Arc::clone(self),
}
}
pub fn open_connections(&self) -> u64 {
self.open_connections.load(Ordering::SeqCst)
}
pub fn rename(&self, from: &str, to: &str) {
if from == to {
return;
}
let mut map = self.endpoints.lock().expect("registry mutex poisoned");
let Some(stats) = map.remove(from) else {
return;
};
match map.get(to) {
Some(existing) => {
existing.add_ingress(stats.ingress());
existing.add_egress(stats.egress());
for _ in 0..stats.connections() {
existing.add_connection();
}
if let Some(ip) = stats.ip() {
existing.observe_ip(ip);
}
}
None => {
map.insert(to.to_owned(), stats);
}
}
}
pub fn hosts(&self) -> Vec<(String, Arc<EndpointStats>)> {
let map = self.endpoints.lock().expect("registry mutex poisoned");
map.iter()
.map(|(host, stats)| (host.clone(), Arc::clone(stats)))
.collect()
}
pub fn snapshot(&self, filter: Option<&str>) -> Vec<Endpoint> {
let mut out: Vec<Endpoint> = self
.hosts()
.into_iter()
.map(|(domain, stats)| Endpoint {
matches_filter: matches_domain(&domain, filter),
ip_address: stats.ip(),
ingress_bytes: stats.ingress(),
egress_bytes: stats.egress(),
connections: stats.connections(),
domain,
})
.collect();
out.sort_by(|a, b| {
b.ingress_bytes
.cmp(&a.ingress_bytes)
.then_with(|| b.egress_bytes.cmp(&a.egress_bytes))
.then_with(|| a.domain.cmp(&b.domain))
});
out
}
}
#[derive(Debug)]
pub struct OpenGuard {
registry: Arc<Registry>,
}
impl Drop for OpenGuard {
fn drop(&mut self) {
self.registry
.open_connections
.fetch_sub(1, Ordering::SeqCst);
}
}
pub fn matches_domain(domain: &str, filter: Option<&str>) -> bool {
let Some(filter) = filter else { return true };
let domain = domain.trim_end_matches('.').to_ascii_lowercase();
let filter = filter
.trim_start_matches("*.")
.trim_matches('.')
.to_ascii_lowercase();
if filter.is_empty() {
return true;
}
domain == filter || domain.ends_with(&format!(".{filter}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn suffix_match_respects_label_boundaries() {
assert!(matches_domain("a.b.example.com", Some("example.com")));
assert!(!matches_domain("badexample.com", Some("example.com")));
assert!(matches_domain("EXAMPLE.com", Some("example.com")));
assert!(matches_domain("x.example.com.", Some(".example.com")));
assert!(matches_domain("x.example.com", Some("*.example.com")));
}
#[test]
fn endpoint_handles_are_shared_per_host() {
let registry = Registry::new();
registry.endpoint("a.example").add_ingress(10);
registry.endpoint("a.example").add_egress(4);
registry.endpoint("a.example").add_connection();
let snap = registry.snapshot(Some("example"));
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].ingress_bytes, 10);
assert_eq!(snap[0].egress_bytes, 4);
assert_eq!(snap[0].connections, 1);
assert!(snap[0].matches_filter);
}
#[test]
fn rename_merges_into_existing_host() {
let registry = Registry::new();
let ip = registry.endpoint("1.2.3.4");
ip.add_ingress(100);
ip.add_connection();
let named = registry.endpoint("host.example");
named.add_ingress(1);
named.add_connection();
registry.rename("1.2.3.4", "host.example");
let snap = registry.snapshot(None);
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].domain, "host.example");
assert_eq!(snap[0].ingress_bytes, 101);
assert_eq!(snap[0].connections, 2);
}
#[test]
fn snapshot_sorts_by_ingress_descending() {
let registry = Registry::new();
registry.endpoint("small").add_ingress(1);
registry.endpoint("big").add_ingress(1_000);
let snap = registry.snapshot(None);
assert_eq!(snap[0].domain, "big");
}
}