upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/metrics/connection.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/metrics/connection.rs')
-rw-r--r--src/metrics/connection.rs337
1 files changed, 337 insertions, 0 deletions
diff --git a/src/metrics/connection.rs b/src/metrics/connection.rs
new file mode 100644
index 0000000..6a7f406
--- /dev/null
+++ b/src/metrics/connection.rs
@@ -0,0 +1,337 @@
1//! Connection tracking with privacy-preserving abuse detection.
2//!
3//! This module tracks WebSocket connections per IP address internally for abuse
4//! detection, but NEVER exposes IP addresses in Prometheus metrics. Only aggregate
5//! counts are exposed.
6//!
7//! # Privacy Model
8//!
9//! | Data | Location | Exposed? |
10//! |------|----------|----------|
11//! | Total connections | Prometheus | ✅ Yes |
12//! | Unique IP count | Prometheus | ✅ Yes |
13//! | Flagged abuser count | Prometheus | ✅ Yes |
14//! | Actual IP addresses | Internal HashMap | ❌ No |
15//! | IP + abuse flag | Logs (when flagged) | ⚠️ Logs only |
16
17use std::net::IpAddr;
18use std::time::Instant;
19
20use dashmap::DashMap;
21use prometheus::{IntGauge, Opts, Registry};
22use tracing::warn;
23
24/// Information about connections from a specific IP address.
25struct ConnectionInfo {
26 /// Number of active connections from this IP
27 count: u32,
28 /// When the first connection from this IP was established
29 first_seen: Instant,
30 /// Whether this IP has been flagged as potentially abusive
31 flagged_as_abuse: bool,
32}
33
34/// Tracks WebSocket connections per IP with abuse detection.
35///
36/// # Thread Safety
37///
38/// Uses `DashMap` for lock-free concurrent access, as connection tracking
39/// happens across multiple tokio tasks.
40///
41/// # Privacy
42///
43/// IP addresses are stored internally only for abuse detection and are
44/// NEVER exposed in Prometheus metrics. Only aggregate counts are exposed:
45/// - Total active connections
46/// - Number of unique IPs
47/// - Number of IPs flagged as potential abusers
48pub struct ConnectionTracker {
49 /// Active connections per IP (INTERNAL ONLY - never exposed to metrics)
50 connections: DashMap<IpAddr, ConnectionInfo>,
51
52 /// Threshold for abuse flagging (connections per IP)
53 abuse_threshold: u32,
54
55 /// Prometheus gauge: total active connections
56 active_connections: IntGauge,
57
58 /// Prometheus gauge: number of unique IPs connected
59 unique_ips: IntGauge,
60
61 /// Prometheus gauge: number of IPs flagged as potential abusers
62 flagged_abusers: IntGauge,
63}
64
65impl ConnectionTracker {
66 /// Creates a new ConnectionTracker and registers metrics with Prometheus.
67 ///
68 /// # Arguments
69 ///
70 /// * `abuse_threshold` - Number of connections from a single IP before flagging
71 /// * `registry` - Prometheus registry to register metrics with
72 pub fn new(abuse_threshold: u32, registry: &Registry) -> Self {
73 let active_connections = IntGauge::with_opts(
74 Opts::new(
75 "ngit_websocket_connections_active",
76 "Current active WebSocket connections",
77 )
78 ).unwrap();
79 registry.register(Box::new(active_connections.clone())).unwrap();
80
81 let unique_ips = IntGauge::with_opts(
82 Opts::new(
83 "ngit_websocket_unique_ips",
84 "Number of unique IP addresses connected (NOT the IPs themselves)",
85 )
86 ).unwrap();
87 registry.register(Box::new(unique_ips.clone())).unwrap();
88
89 let flagged_abusers = IntGauge::with_opts(
90 Opts::new(
91 "ngit_websocket_flagged_abusers",
92 "Number of IPs exceeding connection threshold",
93 )
94 ).unwrap();
95 registry.register(Box::new(flagged_abusers.clone())).unwrap();
96
97 Self {
98 connections: DashMap::new(),
99 abuse_threshold,
100 active_connections,
101 unique_ips,
102 flagged_abusers,
103 }
104 }
105
106 /// Called when a new WebSocket connection is established.
107 ///
108 /// This method:
109 /// 1. Increments the connection count for this IP
110 /// 2. Checks if the IP has exceeded the abuse threshold
111 /// 3. Logs a warning if abuse is detected (IP is logged here only)
112 /// 4. Updates Prometheus metrics (aggregate counts only)
113 ///
114 /// # Privacy
115 ///
116 /// The IP address is logged only when abuse is detected. It is NEVER
117 /// exposed in Prometheus metrics.
118 pub fn on_connect(&self, ip: IpAddr) {
119 let mut is_new_ip = false;
120 let mut newly_flagged = false;
121
122 self.connections
123 .entry(ip)
124 .and_modify(|info| {
125 info.count += 1;
126 // Check if this connection pushes us over the threshold
127 if !info.flagged_as_abuse && info.count >= self.abuse_threshold {
128 info.flagged_as_abuse = true;
129 newly_flagged = true;
130 }
131 })
132 .or_insert_with(|| {
133 is_new_ip = true;
134 ConnectionInfo {
135 count: 1,
136 first_seen: Instant::now(),
137 flagged_as_abuse: false,
138 }
139 });
140
141 // Update Prometheus metrics (aggregate counts only)
142 self.active_connections.inc();
143
144 if is_new_ip {
145 self.unique_ips.inc();
146 }
147
148 if newly_flagged {
149 self.flagged_abusers.inc();
150 // Log the abuse detection - IP is only exposed in logs, not metrics
151 warn!(
152 ip = %ip,
153 threshold = self.abuse_threshold,
154 "Potential abuse detected: IP exceeded connection threshold"
155 );
156 }
157 }
158
159 /// Called when a WebSocket connection is closed.
160 ///
161 /// This method:
162 /// 1. Decrements the connection count for this IP
163 /// 2. Removes the IP from tracking if count reaches 0
164 /// 3. Updates the abuse flag count if the IP was flagged
165 /// 4. Updates Prometheus metrics (aggregate counts only)
166 pub fn on_disconnect(&self, ip: IpAddr) {
167 let mut remove_entry = false;
168 let mut was_flagged = false;
169 let mut had_connection = false;
170
171 if let Some(mut entry) = self.connections.get_mut(&ip) {
172 had_connection = true;
173 entry.count = entry.count.saturating_sub(1);
174 if entry.count == 0 {
175 remove_entry = true;
176 was_flagged = entry.flagged_as_abuse;
177 }
178 }
179
180 // Remove the entry if count is 0
181 if remove_entry {
182 self.connections.remove(&ip);
183 self.unique_ips.dec();
184 if was_flagged {
185 self.flagged_abusers.dec();
186 }
187 }
188
189 // Update total connections only if this IP had a tracked connection
190 if had_connection {
191 self.active_connections.dec();
192 }
193 }
194
195 /// Returns the current number of active connections.
196 pub fn active_connections(&self) -> u64 {
197 self.active_connections.get() as u64
198 }
199
200 /// Returns the current number of unique IPs.
201 pub fn unique_ip_count(&self) -> u64 {
202 self.unique_ips.get() as u64
203 }
204
205 /// Returns the current number of flagged abusers.
206 pub fn flagged_abuser_count(&self) -> u64 {
207 self.flagged_abusers.get() as u64
208 }
209
210 /// Returns the connection count for a specific IP (for internal use only).
211 ///
212 /// # Privacy
213 ///
214 /// This is an internal method. The returned data should NEVER be exposed
215 /// in metrics or logs without privacy consideration.
216 #[cfg(test)]
217 pub(crate) fn connection_count(&self, ip: &IpAddr) -> Option<u32> {
218 self.connections.get(ip).map(|info| info.count)
219 }
220
221 /// Returns whether an IP is flagged as abusive (for internal use only).
222 #[cfg(test)]
223 pub(crate) fn is_flagged(&self, ip: &IpAddr) -> bool {
224 self.connections
225 .get(ip)
226 .map(|info| info.flagged_as_abuse)
227 .unwrap_or(false)
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use std::net::{Ipv4Addr, Ipv6Addr};
235
236 fn test_registry() -> Registry {
237 Registry::new()
238 }
239
240 #[test]
241 fn test_connection_tracking() {
242 let registry = test_registry();
243 let tracker = ConnectionTracker::new(5, &registry);
244 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
245
246 // Connect
247 tracker.on_connect(ip);
248 assert_eq!(tracker.active_connections(), 1);
249 assert_eq!(tracker.unique_ip_count(), 1);
250 assert_eq!(tracker.connection_count(&ip), Some(1));
251
252 // Connect again from same IP
253 tracker.on_connect(ip);
254 assert_eq!(tracker.active_connections(), 2);
255 assert_eq!(tracker.unique_ip_count(), 1); // Still 1 unique IP
256 assert_eq!(tracker.connection_count(&ip), Some(2));
257
258 // Disconnect one
259 tracker.on_disconnect(ip);
260 assert_eq!(tracker.active_connections(), 1);
261 assert_eq!(tracker.unique_ip_count(), 1);
262 assert_eq!(tracker.connection_count(&ip), Some(1));
263
264 // Disconnect last
265 tracker.on_disconnect(ip);
266 assert_eq!(tracker.active_connections(), 0);
267 assert_eq!(tracker.unique_ip_count(), 0);
268 assert_eq!(tracker.connection_count(&ip), None);
269 }
270
271 #[test]
272 fn test_multiple_ips() {
273 let registry = test_registry();
274 let tracker = ConnectionTracker::new(5, &registry);
275 let ip1 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
276 let ip2 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2));
277 let ip3 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
278
279 tracker.on_connect(ip1);
280 tracker.on_connect(ip2);
281 tracker.on_connect(ip3);
282
283 assert_eq!(tracker.active_connections(), 3);
284 assert_eq!(tracker.unique_ip_count(), 3);
285
286 tracker.on_disconnect(ip2);
287 assert_eq!(tracker.active_connections(), 2);
288 assert_eq!(tracker.unique_ip_count(), 2);
289 }
290
291 #[test]
292 fn test_abuse_detection() {
293 let registry = test_registry();
294 let threshold = 3;
295 let tracker = ConnectionTracker::new(threshold, &registry);
296 let abuser_ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
297 let normal_ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2));
298
299 // Normal user with 1 connection
300 tracker.on_connect(normal_ip);
301 assert!(!tracker.is_flagged(&normal_ip));
302 assert_eq!(tracker.flagged_abuser_count(), 0);
303
304 // Abuser approaching threshold
305 tracker.on_connect(abuser_ip);
306 tracker.on_connect(abuser_ip);
307 assert!(!tracker.is_flagged(&abuser_ip));
308 assert_eq!(tracker.flagged_abuser_count(), 0);
309
310 // Abuser hits threshold
311 tracker.on_connect(abuser_ip);
312 assert!(tracker.is_flagged(&abuser_ip));
313 assert_eq!(tracker.flagged_abuser_count(), 1);
314
315 // Normal user still not flagged
316 assert!(!tracker.is_flagged(&normal_ip));
317
318 // Abuser disconnects all - should be removed from flagged count
319 tracker.on_disconnect(abuser_ip);
320 tracker.on_disconnect(abuser_ip);
321 tracker.on_disconnect(abuser_ip);
322 assert_eq!(tracker.flagged_abuser_count(), 0);
323 assert_eq!(tracker.active_connections(), 1); // Only normal user remains
324 }
325
326 #[test]
327 fn test_disconnect_without_connect() {
328 let registry = test_registry();
329 let tracker = ConnectionTracker::new(5, &registry);
330 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
331
332 // Disconnect without connect should not panic or go negative
333 tracker.on_disconnect(ip);
334 assert_eq!(tracker.active_connections(), 0);
335 assert_eq!(tracker.unique_ip_count(), 0);
336 }
337} \ No newline at end of file