g1t/crates/runner/src/abuse.rs
| 1 | //! Catching cryptocurrency mining in a sandbox. |
| 2 | //! |
| 3 | //! Mining needs two things a g1t sandbox tries not to give it: a route to a |
| 4 | //! mining pool, which the network allowlist refuses, and hours of CPU, which |
| 5 | //! this module watches for. Three layers: |
| 6 | //! |
| 7 | //! 1. **Known miners, by name.** `miner_in` matches miner programs (xmrig, |
| 8 | //! cpuminer, ...), pool URLs (`stratum+tcp://`), their flags |
| 9 | //! (`--donate-level`) and well-known pools. The agent harness's command |
| 10 | //! hook refuses a matching shell command (guard.rs), checks, deploy |
| 11 | //! builds and workflow steps refuse to run one, and the sampler below |
| 12 | //! stops the sandbox if a running process's command line matches. |
| 13 | //! 2. **The CPU signature.** A background thread samples the sandbox every |
| 14 | //! `SAMPLE_SECONDS`: CPU use (from `/proc/stat`), file and disk I/O |
| 15 | //! (`/proc/diskstats` and every process's `/proc/<pid>/io`), network |
| 16 | //! bytes (`/proc/net/dev`), how many new processes started, and whether |
| 17 | //! the run did anything a person would call progress (a tool call, an |
| 18 | //! agent step, a new check command or workflow step). `Detector` flags a |
| 19 | //! sandbox only when, for a whole `WINDOW_SECONDS` (10 minutes): |
| 20 | //! - CPU stayed at or above `MIN_CPU` (90%) in every sample but one; |
| 21 | //! - file and disk I/O averaged under `MAX_IO_PER_SECOND` (64 KB/s); |
| 22 | //! - the network averaged under `MAX_NET_PER_SECOND` (16 KB/s); |
| 23 | //! - fewer than `MAX_NEW_PROCESSES` (5) processes started; and |
| 24 | //! - there was no progress at all. |
| 25 | //! |
| 26 | //! Compilers and test suites are CPU-bound too, but they read sources, |
| 27 | //! write objects and start processes (a `cargo build` or `npm test` |
| 28 | //! starts hundreds), so they fail the I/O or process test. A single |
| 29 | //! compiler process can sit in code generation for minutes with little |
| 30 | //! I/O, so when the busiest process is a known toolchain (`TOOLCHAINS`) |
| 31 | //! and nothing matches a miner by name, it is not flagged. |
| 32 | //! 3. **What happens.** The sandbox tells the runner service (an HTTP |
| 33 | //! request to `REPORT_URL`, which the runner's Durable Object answers |
| 34 | //! without it leaving the machine), kills every other process, and |
| 35 | //! exits with `EXIT_CODE`. The runner marks the run "Stopped: unusual |
| 36 | //! CPU use; contact support if this was a real job" and emits |
| 37 | //! `abuse.flagged` for g1t's staff, with these metrics. |
| 38 | |
| 39 | use std::collections::{BTreeMap, VecDeque}; |
| 40 | use std::sync::atomic::{AtomicU64, Ordering}; |
| 41 | use std::time::Duration; |
| 42 | |
| 43 | use serde::Serialize; |
| 44 | |
| 45 | /// How often the sandbox is sampled. |
| 46 | pub const SAMPLE_SECONDS: u64 = 30; |
| 47 | /// How long the signature must hold before a run is flagged. |
| 48 | pub const WINDOW_SECONDS: u64 = 10 * 60; |
| 49 | /// CPU use, of all the sandbox's CPUs, that counts as pinned. |
| 50 | pub const MIN_CPU: f64 = 0.90; |
| 51 | /// Samples in a window that may dip under `MIN_CPU` (a miner's own pauses). |
| 52 | pub const ALLOWED_DIPS: usize = 1; |
| 53 | /// File and disk I/O, in bytes a second, under which a run counts as doing none. |
| 54 | pub const MAX_IO_PER_SECOND: f64 = 64.0 * 1024.0; |
| 55 | /// Network, in bytes a second, under which a run counts as quiet. |
| 56 | pub const MAX_NET_PER_SECOND: f64 = 16.0 * 1024.0; |
| 57 | /// New processes in a window under which a run counts as not building. |
| 58 | pub const MAX_NEW_PROCESSES: u64 = 5; |
| 59 | /// What the sandbox exits with when it stops itself for mining. |
| 60 | pub const EXIT_CODE: i32 = 86; |
| 61 | /// Where the sandbox tells the runner; answered by the runner's Durable |
| 62 | /// Object, never sent anywhere. |
| 63 | pub const REPORT_URL: &str = "http://sandbox.g1t.internal/abuse"; |
| 64 | |
| 65 | /// Programs whose name alone says they mine. Matched anywhere in a command |
| 66 | /// line, so downloads of them are caught too. |
| 67 | const MINER_NAMES: &[&str] = &[ |
| 68 | "xmrig", |
| 69 | "xmr-stak", |
| 70 | "cpuminer", |
| 71 | "minerd", |
| 72 | "ccminer", |
| 73 | "cgminer", |
| 74 | "bfgminer", |
| 75 | "ethminer", |
| 76 | "nbminer", |
| 77 | "lolminer", |
| 78 | "phoenixminer", |
| 79 | "nanominer", |
| 80 | "srbminer", |
| 81 | "teamredminer", |
| 82 | "bzminer", |
| 83 | "nheqminer", |
| 84 | "xmrminer", |
| 85 | ]; |
| 86 | |
| 87 | /// Programs matched only as a whole program name: as words they are too |
| 88 | /// common. |
| 89 | const MINER_PROGRAMS: &[&str] = &["t-rex", "gminer", "wildrig", "rigel"]; |
| 90 | |
| 91 | /// Arguments and addresses only miners use. |
| 92 | const MINER_ARGS: &[&str] = &[ |
| 93 | "stratum+tcp://", |
| 94 | "stratum+ssl://", |
| 95 | "stratum+tls://", |
| 96 | "stratum2+tcp://", |
| 97 | "--donate-level", |
| 98 | "--cpu-max-threads-hint", |
| 99 | "--randomx-mode", |
| 100 | "--algo=rx/", |
| 101 | "--algo rx/", |
| 102 | "-a rx/0", |
| 103 | "--algo=cryptonight", |
| 104 | "-a cryptonight", |
| 105 | "--coin=monero", |
| 106 | "--coin monero", |
| 107 | "supportxmr.com", |
| 108 | "moneroocean.stream", |
| 109 | "minexmr.com", |
| 110 | "nanopool.org", |
| 111 | "2miners.com", |
| 112 | "f2pool.com", |
| 113 | "hashvault.pro", |
| 114 | "unmineable.com", |
| 115 | "nicehash.com", |
| 116 | "herominers.com", |
| 117 | "c3pool.com", |
| 118 | ]; |
| 119 | |
| 120 | /// Toolchains that can keep a CPU busy for minutes with little I/O while |
| 121 | /// they generate code. Busiest-process names, as `/proc/<pid>/stat` gives |
| 122 | /// them (cut to 15 characters). |
| 123 | const TOOLCHAINS: &[&str] = &[ |
| 124 | "rustc", "cc1", "cc1plus", "clang", "clang++", "ld", "ld.lld", "lld", "mold", "go", "compile", "link", |
| 125 | "javac", "java", "kotlinc", "scalac", "swift-frontend", "ghc", "node", "tsc", "esbuild", "webpack", "python3", |
| 126 | "python", "pytest", "cargo", "gcc", "g++", "rust-analyzer", "dotnet", |
| 127 | ]; |
| 128 | |
| 129 | /// The miner a command line names, if it names one. |
| 130 | pub fn miner_in(command: &str) -> Option<&'static str> { |
| 131 | let text = command.to_lowercase(); |
| 132 | if let Some(name) = MINER_NAMES.iter().find(|name| text.contains(*name)) { |
| 133 | return Some(name); |
| 134 | } |
| 135 | if let Some(arg) = MINER_ARGS.iter().find(|arg| text.contains(*arg)) { |
| 136 | return Some(arg); |
| 137 | } |
| 138 | text.split(|c: char| c.is_whitespace() || matches!(c, ';' | '|' | '&' | '(' | ')' | '`' | '"' | '\'')) |
| 139 | .map(|word| word.rsplit('/').next().unwrap_or(word)) |
| 140 | .find_map(|program| MINER_PROGRAMS.iter().find(|name| **name == program).copied()) |
| 141 | } |
| 142 | |
| 143 | /// What the run has done that a person would call progress, bumped by the |
| 144 | /// harness on each tool call and step, and by checks and workflows on each |
| 145 | /// command or step they start. |
| 146 | static ACTIVITY: AtomicU64 = AtomicU64::new(0); |
| 147 | |
| 148 | /// Notes progress, so a busy CPU is not mistaken for a miner's. |
| 149 | pub fn touch() { |
| 150 | ACTIVITY.fetch_add(1, Ordering::Relaxed); |
| 151 | } |
| 152 | |
| 153 | /// One reading of the sandbox. Counters are totals since boot; the |
| 154 | /// detector works on the differences between readings. |
| 155 | #[derive(Clone, Debug, Default)] |
| 156 | pub struct Sample { |
| 157 | /// Seconds, on any steady clock. |
| 158 | pub at: u64, |
| 159 | pub cpu_busy: u64, |
| 160 | pub cpu_total: u64, |
| 161 | /// File and disk bytes read and written. |
| 162 | pub io_bytes: u64, |
| 163 | pub net_bytes: u64, |
| 164 | /// Processes seen for the first time since the last sample. |
| 165 | pub new_processes: u64, |
| 166 | pub activity: u64, |
| 167 | /// The process that used the most CPU since the last sample, by name. |
| 168 | pub busiest: Option<String>, |
| 169 | /// A running process whose command line names a miner, if any. |
| 170 | pub miner: Option<String>, |
| 171 | } |
| 172 | |
| 173 | /// Why a sandbox was flagged, as reported and shown to g1t's staff. |
| 174 | #[derive(Clone, Debug, PartialEq, Serialize)] |
| 175 | pub struct Verdict { |
| 176 | /// `cpu` for the signature, `miner` for a miner seen by name. |
| 177 | pub reason: &'static str, |
| 178 | /// Average CPU use over the window, 0 to 1. |
| 179 | pub cpu: f64, |
| 180 | pub io_bytes_per_second: f64, |
| 181 | pub net_bytes_per_second: f64, |
| 182 | pub new_processes: u64, |
| 183 | pub window_seconds: u64, |
| 184 | pub busiest: Option<String>, |
| 185 | pub matched: Option<String>, |
| 186 | } |
| 187 | |
| 188 | /// Watches samples for the signature of mining. |
| 189 | #[derive(Default)] |
| 190 | pub struct Detector { |
| 191 | samples: VecDeque<Sample>, |
| 192 | } |
| 193 | |
| 194 | impl Detector { |
| 195 | /// Takes a sample; returns a verdict the first time the signature holds. |
| 196 | pub fn observe(&mut self, sample: Sample) -> Option<Verdict> { |
| 197 | if let Some(matched) = sample.miner.clone() { |
| 198 | return Some(Verdict { |
| 199 | reason: "miner", |
| 200 | cpu: 0.0, |
| 201 | io_bytes_per_second: 0.0, |
| 202 | net_bytes_per_second: 0.0, |
| 203 | new_processes: sample.new_processes, |
| 204 | window_seconds: 0, |
| 205 | busiest: sample.busiest.clone(), |
| 206 | matched: Some(matched), |
| 207 | }); |
| 208 | } |
| 209 | self.samples.push_back(sample); |
| 210 | // Keep just over one window: the oldest sample is its start. |
| 211 | while self.samples.len() > 2 |
| 212 | && self.samples[1].at + WINDOW_SECONDS <= self.samples.back().map_or(0, |last| last.at) |
| 213 | { |
| 214 | self.samples.pop_front(); |
| 215 | } |
| 216 | let first = self.samples.front()?; |
| 217 | let last = self.samples.back()?; |
| 218 | let span = last.at.saturating_sub(first.at); |
| 219 | if span < WINDOW_SECONDS { |
| 220 | return None; |
| 221 | } |
| 222 | let pairs: Vec<(&Sample, &Sample)> = self.samples.iter().zip(self.samples.iter().skip(1)).collect(); |
| 223 | let uses: Vec<f64> = pairs |
| 224 | .iter() |
| 225 | .map(|(a, b)| { |
| 226 | let total = b.cpu_total.saturating_sub(a.cpu_total); |
| 227 | if total == 0 { 0.0 } else { b.cpu_busy.saturating_sub(a.cpu_busy) as f64 / total as f64 } |
| 228 | }) |
| 229 | .collect(); |
| 230 | let dips = uses.iter().filter(|cpu| **cpu < MIN_CPU).count(); |
| 231 | if dips > ALLOWED_DIPS { |
| 232 | return None; |
| 233 | } |
| 234 | let seconds = span as f64; |
| 235 | let io = last.io_bytes.saturating_sub(first.io_bytes) as f64 / seconds; |
| 236 | let net = last.net_bytes.saturating_sub(first.net_bytes) as f64 / seconds; |
| 237 | let new_processes: u64 = self.samples.iter().skip(1).map(|s| s.new_processes).sum(); |
| 238 | if io >= MAX_IO_PER_SECOND || net >= MAX_NET_PER_SECOND || new_processes >= MAX_NEW_PROCESSES { |
| 239 | return None; |
| 240 | } |
| 241 | if last.activity != first.activity { |
| 242 | return None; |
| 243 | } |
| 244 | // A compiler deep in code generation: busy, quiet, and expected. |
| 245 | if last.busiest.as_deref().is_some_and(|name| TOOLCHAINS.contains(&name)) { |
| 246 | return None; |
| 247 | } |
| 248 | Some(Verdict { |
| 249 | reason: "cpu", |
| 250 | cpu: uses.iter().sum::<f64>() / uses.len().max(1) as f64, |
| 251 | io_bytes_per_second: io, |
| 252 | net_bytes_per_second: net, |
| 253 | new_processes, |
| 254 | window_seconds: span, |
| 255 | busiest: last.busiest.clone(), |
| 256 | matched: None, |
| 257 | }) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // ---- Reading /proc ------------------------------------------------------------ |
| 262 | |
| 263 | /// Busy and total CPU ticks from `/proc/stat`'s first line. |
| 264 | pub fn parse_cpu(stat: &str) -> Option<(u64, u64)> { |
| 265 | let line = stat.lines().find(|line| line.starts_with("cpu "))?; |
| 266 | let values: Vec<u64> = line.split_whitespace().skip(1).filter_map(|v| v.parse().ok()).collect(); |
| 267 | if values.len() < 4 { |
| 268 | return None; |
| 269 | } |
| 270 | // user nice system idle iowait irq softirq steal: guest time is in user. |
| 271 | let counted = &values[..values.len().min(8)]; |
| 272 | let total: u64 = counted.iter().sum(); |
| 273 | let idle = values[3] + values.get(4).copied().unwrap_or(0); |
| 274 | Some((total.saturating_sub(idle), total)) |
| 275 | } |
| 276 | |
| 277 | /// Bytes received and sent on every interface but loopback, from `/proc/net/dev`. |
| 278 | pub fn parse_net(dev: &str) -> u64 { |
| 279 | dev.lines() |
| 280 | .skip(2) |
| 281 | .filter_map(|line| { |
| 282 | let (name, rest) = line.split_once(':')?; |
| 283 | if name.trim() == "lo" { |
| 284 | return None; |
| 285 | } |
| 286 | let fields: Vec<u64> = rest.split_whitespace().filter_map(|v| v.parse().ok()).collect(); |
| 287 | Some(fields.first().copied().unwrap_or(0) + fields.get(8).copied().unwrap_or(0)) |
| 288 | }) |
| 289 | .sum() |
| 290 | } |
| 291 | |
| 292 | /// Bytes read and written on whole disks, from `/proc/diskstats`. |
| 293 | pub fn parse_disks(stats: &str) -> u64 { |
| 294 | stats |
| 295 | .lines() |
| 296 | .filter_map(|line| { |
| 297 | let fields: Vec<&str> = line.split_whitespace().collect(); |
| 298 | let name = *fields.get(2)?; |
| 299 | let virtual_device = ["loop", "ram", "dm-", "zram", "sr"].iter().any(|prefix| name.starts_with(prefix)); |
| 300 | let partition = if name.starts_with("nvme") || name.starts_with("mmcblk") { |
| 301 | name.contains('p') |
| 302 | } else { |
| 303 | name.ends_with(|c: char| c.is_ascii_digit()) |
| 304 | }; |
| 305 | if virtual_device || partition { |
| 306 | return None; |
| 307 | } |
| 308 | let read: u64 = fields.get(5)?.parse().ok()?; |
| 309 | let written: u64 = fields.get(9)?.parse().ok()?; |
| 310 | Some((read + written) * 512) |
| 311 | }) |
| 312 | .sum() |
| 313 | } |
| 314 | |
| 315 | /// A process's name and CPU ticks (user + system), from `/proc/<pid>/stat`. |
| 316 | pub fn parse_proc_stat(stat: &str) -> Option<(String, u64)> { |
| 317 | let open = stat.find('(')?; |
| 318 | let close = stat.rfind(')')?; |
| 319 | let name = stat.get(open + 1..close)?.to_owned(); |
| 320 | let rest: Vec<&str> = stat.get(close + 1..)?.split_whitespace().collect(); |
| 321 | // After the name: state is field 3, utime 14 and stime 15 (1-based). |
| 322 | let utime: u64 = rest.get(11)?.parse().ok()?; |
| 323 | let stime: u64 = rest.get(12)?.parse().ok()?; |
| 324 | Some((name, utime + stime)) |
| 325 | } |
| 326 | |
| 327 | /// Characters read and written by a process, from `/proc/<pid>/io`. |
| 328 | pub fn parse_proc_io(io: &str) -> u64 { |
| 329 | io.lines() |
| 330 | .filter_map(|line| { |
| 331 | let (key, value) = line.split_once(':')?; |
| 332 | matches!(key.trim(), "rchar" | "wchar").then(|| value.trim().parse::<u64>().ok()).flatten() |
| 333 | }) |
| 334 | .sum() |
| 335 | } |
| 336 | |
| 337 | /// What the sampler remembers between readings. |
| 338 | #[derive(Default)] |
| 339 | struct Processes { |
| 340 | /// CPU ticks and characters of I/O by process id, as last read. |
| 341 | seen: BTreeMap<u32, (u64, u64)>, |
| 342 | /// I/O of processes that have exited, so totals never go back. |
| 343 | io_carried: u64, |
| 344 | } |
| 345 | |
| 346 | fn read(path: &str) -> String { |
| 347 | std::fs::read_to_string(path).unwrap_or_default() |
| 348 | } |
| 349 | |
| 350 | impl Processes { |
| 351 | /// Reads every process: new ones, the busiest, total I/O, and any miner. |
| 352 | fn read(&mut self, own: u32) -> (u64, Option<String>, u64, Option<String>) { |
| 353 | let mut now: BTreeMap<u32, (u64, u64)> = BTreeMap::new(); |
| 354 | let mut new = 0; |
| 355 | let mut busiest: Option<(u64, String)> = None; |
| 356 | let mut miner = None; |
| 357 | let Ok(entries) = std::fs::read_dir("/proc") else { |
| 358 | return (0, None, 0, None); |
| 359 | }; |
| 360 | for entry in entries.flatten() { |
| 361 | let Some(pid) = entry.file_name().to_str().and_then(|name| name.parse::<u32>().ok()) else { |
| 362 | continue; |
| 363 | }; |
| 364 | let Some((name, ticks)) = parse_proc_stat(&read(&format!("/proc/{pid}/stat"))) else { |
| 365 | continue; |
| 366 | }; |
| 367 | let io = parse_proc_io(&read(&format!("/proc/{pid}/io"))); |
| 368 | let before = self.seen.get(&pid).copied(); |
| 369 | if before.is_none() { |
| 370 | new += 1; |
| 371 | } |
| 372 | let used = ticks.saturating_sub(before.map_or(ticks, |(t, _)| t)); |
| 373 | if pid != own && busiest.as_ref().is_none_or(|(most, _)| used > *most) { |
| 374 | busiest = Some((used, name)); |
| 375 | } |
| 376 | if pid != own && miner.is_none() { |
| 377 | let cmdline = read(&format!("/proc/{pid}/cmdline")).replace('\0', " "); |
| 378 | miner = miner_in(&cmdline).map(|matched| format!("{matched} in `{}`", cmdline.trim())); |
| 379 | } |
| 380 | now.insert(pid, (ticks, io)); |
| 381 | } |
| 382 | for (pid, (_, io)) in &self.seen { |
| 383 | if !now.contains_key(pid) { |
| 384 | self.io_carried += io; |
| 385 | } |
| 386 | } |
| 387 | let io_total = self.io_carried + now.values().map(|(_, io)| io).sum::<u64>(); |
| 388 | // The first reading sees every process as new: that is not churn. |
| 389 | let first = self.seen.is_empty(); |
| 390 | self.seen = now; |
| 391 | (if first { 0 } else { new }, busiest.map(|(_, name)| name), io_total, miner) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | /// Starts the sampler in the background. When it flags the sandbox it |
| 396 | /// reports, stops every other process and exits with `EXIT_CODE`. |
| 397 | /// Off where `/proc` cannot be read (not Linux), or with `G1T_ABUSE=off`. |
| 398 | pub fn watch() { |
| 399 | if std::env::var("G1T_ABUSE").as_deref() == Ok("off") || !std::path::Path::new("/proc/stat").exists() { |
| 400 | return; |
| 401 | } |
| 402 | std::thread::spawn(|| { |
| 403 | let started = std::time::Instant::now(); |
| 404 | let own = std::process::id(); |
| 405 | let mut detector = Detector::default(); |
| 406 | let mut processes = Processes::default(); |
| 407 | loop { |
| 408 | let (cpu_busy, cpu_total) = parse_cpu(&read("/proc/stat")).unwrap_or((0, 0)); |
| 409 | let (new_processes, busiest, process_io, miner) = processes.read(own); |
| 410 | let sample = Sample { |
| 411 | at: started.elapsed().as_secs(), |
| 412 | cpu_busy, |
| 413 | cpu_total, |
| 414 | io_bytes: parse_disks(&read("/proc/diskstats")) + process_io, |
| 415 | net_bytes: parse_net(&read("/proc/net/dev")), |
| 416 | new_processes, |
| 417 | activity: ACTIVITY.load(Ordering::Relaxed), |
| 418 | busiest, |
| 419 | miner, |
| 420 | }; |
| 421 | if let Some(verdict) = detector.observe(sample) { |
| 422 | stop(&verdict, own); |
| 423 | } |
| 424 | std::thread::sleep(Duration::from_secs(SAMPLE_SECONDS)); |
| 425 | } |
| 426 | }); |
| 427 | } |
| 428 | |
| 429 | /// Reports the verdict, kills everything else and exits. |
| 430 | fn stop(verdict: &Verdict, own: u32) -> ! { |
| 431 | eprintln!("g1t-runner: stopped for unusual CPU use: {}", serde_json::to_string(verdict).unwrap_or_default()); |
| 432 | let _ = ureq::post(REPORT_URL) |
| 433 | .timeout(Duration::from_secs(10)) |
| 434 | .send_json(serde_json::json!({ "verdict": verdict })); |
| 435 | if let Ok(entries) = std::fs::read_dir("/proc") { |
| 436 | let pids: Vec<String> = entries |
| 437 | .flatten() |
| 438 | .filter_map(|entry| entry.file_name().to_str().and_then(|name| name.parse::<u32>().ok())) |
| 439 | .filter(|pid| *pid != own && *pid != 1) |
| 440 | .map(|pid| pid.to_string()) |
| 441 | .collect(); |
| 442 | if !pids.is_empty() { |
| 443 | // As root where the sandbox still can; as itself otherwise. |
| 444 | let as_root = std::process::Command::new("sudo") |
| 445 | .args(["-n", "kill", "-9"]) |
| 446 | .args(&pids) |
| 447 | .stdin(std::process::Stdio::null()) |
| 448 | .stderr(std::process::Stdio::null()) |
| 449 | .status() |
| 450 | .is_ok_and(|status| status.success()); |
| 451 | if !as_root { |
| 452 | let _ = std::process::Command::new("kill").arg("-9").args(&pids).status(); |
| 453 | } |
| 454 | } |
| 455 | } |
| 456 | std::process::exit(EXIT_CODE) |
| 457 | } |
| 458 | |
| 459 | #[cfg(test)] |
| 460 | mod tests { |
| 461 | use super::*; |
| 462 | |
| 463 | /// A sandbox whose CPU runs at `cpu` (0 to 1), with this much I/O, |
| 464 | /// network and process churn each sample, sampled for `minutes`. |
| 465 | fn run(minutes: u64, cpu: f64, io_per_sample: u64, net_per_sample: u64, new_per_sample: u64, busiest: &str, active: bool) -> Option<Verdict> { |
| 466 | let mut detector = Detector::default(); |
| 467 | let mut sample = Sample::default(); |
| 468 | let mut verdict = None; |
| 469 | for i in 0..=(minutes * 60 / SAMPLE_SECONDS) { |
| 470 | sample.at = i * SAMPLE_SECONDS; |
| 471 | sample.cpu_total += 1000; |
| 472 | sample.cpu_busy += (cpu * 1000.0) as u64; |
| 473 | sample.io_bytes += io_per_sample; |
| 474 | sample.net_bytes += net_per_sample; |
| 475 | sample.new_processes = if i == 0 { 0 } else { new_per_sample }; |
| 476 | if active { |
| 477 | sample.activity += 1; |
| 478 | } |
| 479 | sample.busiest = Some(busiest.to_owned()); |
| 480 | if verdict.is_none() { |
| 481 | verdict = detector.observe(sample.clone()); |
| 482 | } |
| 483 | } |
| 484 | verdict |
| 485 | } |
| 486 | |
| 487 | #[test] |
| 488 | fn a_pinned_quiet_process_is_flagged_after_ten_minutes() { |
| 489 | assert!(run(9, 0.99, 1_000, 500, 0, "kworker", false).is_none()); |
| 490 | let verdict = run(11, 0.99, 1_000, 500, 0, "kworker", false).expect("flagged"); |
| 491 | assert_eq!(verdict.reason, "cpu"); |
| 492 | assert!(verdict.cpu > 0.95); |
| 493 | assert!(verdict.window_seconds >= WINDOW_SECONDS); |
| 494 | } |
| 495 | |
| 496 | #[test] |
| 497 | fn a_compile_that_reads_and_writes_is_not_flagged() { |
| 498 | // 30 MB of I/O a sample: a build writing objects. |
| 499 | assert!(run(30, 1.0, 30 * 1024 * 1024, 0, 0, "unknown", false).is_none()); |
| 500 | } |
| 501 | |
| 502 | #[test] |
| 503 | fn a_test_suite_that_starts_processes_is_not_flagged() { |
| 504 | assert!(run(30, 1.0, 1_000, 0, 3, "unknown", false).is_none()); |
| 505 | } |
| 506 | |
| 507 | #[test] |
| 508 | fn a_compiler_in_code_generation_is_not_flagged() { |
| 509 | assert!(run(30, 1.0, 0, 0, 0, "rustc", false).is_none()); |
| 510 | assert!(run(30, 1.0, 0, 0, 0, "cc1plus", false).is_none()); |
| 511 | } |
| 512 | |
| 513 | #[test] |
| 514 | fn an_agent_that_keeps_working_is_not_flagged() { |
| 515 | assert!(run(30, 1.0, 0, 0, 0, "unknown", true).is_none()); |
| 516 | } |
| 517 | |
| 518 | #[test] |
| 519 | fn half_a_cpu_is_not_pinned() { |
| 520 | assert!(run(30, 0.6, 0, 0, 0, "unknown", false).is_none()); |
| 521 | } |
| 522 | |
| 523 | #[test] |
| 524 | fn a_chatty_process_is_not_quiet() { |
| 525 | // 1 MB a sample on the network: a download or a server, not a |
| 526 | // miner whose pool the allowlist refused. |
| 527 | assert!(run(30, 1.0, 0, 1024 * 1024, 0, "unknown", false).is_none()); |
| 528 | } |
| 529 | |
| 530 | #[test] |
| 531 | fn one_dip_does_not_save_a_miner() { |
| 532 | let mut detector = Detector::default(); |
| 533 | let mut sample = Sample::default(); |
| 534 | let mut flagged = false; |
| 535 | for i in 0..=22 { |
| 536 | sample.at = i * SAMPLE_SECONDS; |
| 537 | sample.cpu_total += 1000; |
| 538 | sample.cpu_busy += if i == 5 { 300 } else { 990 }; |
| 539 | flagged |= detector.observe(sample.clone()).is_some(); |
| 540 | } |
| 541 | assert!(flagged); |
| 542 | } |
| 543 | |
| 544 | #[test] |
| 545 | fn a_miner_seen_by_name_is_flagged_at_once() { |
| 546 | let mut detector = Detector::default(); |
| 547 | let verdict = detector |
| 548 | .observe(Sample { miner: Some("xmrig".to_owned()), ..Sample::default() }) |
| 549 | .expect("flagged"); |
| 550 | assert_eq!(verdict.reason, "miner"); |
| 551 | } |
| 552 | |
| 553 | #[test] |
| 554 | fn miners_are_known_by_name_argument_and_pool() { |
| 555 | assert_eq!(miner_in("./xmrig -o pool:3333"), Some("xmrig")); |
| 556 | assert_eq!(miner_in("wget https://github.com/xmrig/xmrig/releases/x.tar.gz"), Some("xmrig")); |
| 557 | assert_eq!(miner_in("./a.out -o stratum+tcp://pool:4444 -u wallet"), Some("stratum+tcp://")); |
| 558 | assert_eq!(miner_in("./run --donate-level 1"), Some("--donate-level")); |
| 559 | assert_eq!(miner_in("./m -o gulf.moneroocean.stream:10128"), Some("moneroocean.stream")); |
| 560 | assert_eq!(miner_in("nohup /tmp/t-rex -a kawpow"), Some("t-rex")); |
| 561 | assert_eq!(miner_in("CPUMINER --algo=sha256d"), Some("cpuminer")); |
| 562 | } |
| 563 | |
| 564 | #[test] |
| 565 | fn ordinary_commands_are_not_miners() { |
| 566 | for command in [ |
| 567 | "cargo build --release", |
| 568 | "npm test", |
| 569 | "git log --oneline", |
| 570 | "cat docs/t-rex.md", |
| 571 | "grep -r stratum src/", |
| 572 | "python3 -m pytest -k mining_model", |
| 573 | "go test ./...", |
| 574 | "rg gminer_config", |
| 575 | ] { |
| 576 | assert_eq!(miner_in(command), None, "{command}"); |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | #[test] |
| 581 | fn proc_files_read_as_the_kernel_writes_them() { |
| 582 | let stat = "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 100 0 50 800 50 0 0 0 0 0\n"; |
| 583 | assert_eq!(parse_cpu(stat), Some((150, 1000))); |
| 584 | let dev = "Inter-| Receive\n face |bytes\n lo: 999 1 0 0 0 0 0 0 999 1 0 0 0 0 0 0\n eth0: 1000 2 0 0 0 0 0 0 500 3 0 0 0 0 0 0\n"; |
| 585 | assert_eq!(parse_net(dev), 1500); |
| 586 | let disks = " 8 0 sda 10 0 100 0 5 0 50 0 0 0 0\n 8 1 sda1 10 0 100 0 5 0 50 0 0 0 0\n 7 0 loop0 1 0 8 0 0 0 0 0 0 0 0\n 259 0 nvme0n1 1 0 2 0 1 0 2 0 0 0 0\n 259 1 nvme0n1p1 1 0 2 0 1 0 2 0 0 0 0\n"; |
| 587 | assert_eq!(parse_disks(disks), (150 + 4) * 512); |
| 588 | let proc_stat = "42 (my (odd) name) R 1 42 42 0 -1 4194304 100 0 0 0 700 300 0 0 20 0 1 0 1000"; |
| 589 | assert_eq!(parse_proc_stat(proc_stat), Some(("my (odd) name".to_owned(), 1000))); |
| 590 | assert_eq!(parse_proc_io("rchar: 100\nwchar: 50\nsyscr: 3\nread_bytes: 4096\n"), 150); |
| 591 | } |
| 592 | } |