├── src ├── utils │ ├── mod.rs │ └── format.rs ├── core │ ├── mod.rs │ ├── constants.rs │ ├── error.rs │ ├── logger.rs │ └── config.rs ├── monitoring │ ├── mod.rs │ ├── process.rs │ ├── dbus.rs │ ├── scanner.rs │ └── filesystem.rs └── rspy.rs ├── .gitignore ├── README.md ├── Cargo.toml └── .github └── workflows └── release.yml /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod format; 2 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod constants; 3 | pub mod error; 4 | pub mod logger; 5 | -------------------------------------------------------------------------------- /src/monitoring/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dbus; 2 | pub mod filesystem; 3 | pub mod process; 4 | pub mod scanner; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | debug/ 2 | target/ 3 | 4 | **/*.rs.bk 5 | 6 | Cargo.lock 7 | 8 | *.pdb 9 | 10 | Makefile 11 | docker/ 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | rust port of https://github.com/DominicBreuker/pspy with support for process monitoring over dbus. 2 | 3 | cross is needed for cross-compilation due to the usage of dbus-rs lib within rspy. 4 | ``` 5 | cargo install cross --git https://github.com/cross-rs/cross 6 | cross build --target x86_64-unknown-linux-musl --release 7 | ``` 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rspy" 3 | version = "1.0.0" 4 | edition = "2024" 5 | build = "build.rs" 6 | 7 | [[bin]] 8 | name = "rspy" 9 | path = "src/rspy.rs" 10 | 11 | [profile.release] 12 | strip = true 13 | opt-level = "z" 14 | lto = true 15 | codegen-units = 1 16 | debug = false 17 | 18 | [dependencies] 19 | libc = "0.2" 20 | procfs = "0.11.0" 21 | walkdir = "2.3" 22 | 23 | # https://github.com/diwic/dbus-rs/blob/master/libdbus-sys/cross_compile.md 24 | dbus = {version = "0.9.7", features = ["vendored"]} 25 | 26 | clap = { version = "4.4", features = ["derive"] } 27 | log = "0.4.14" 28 | colored = "2.0.0" 29 | chrono = "0.4" 30 | thiserror = "1.0" 31 | ctrlc = "3.4" 32 | rustc-hash = "1.1" -------------------------------------------------------------------------------- /src/core/constants.rs: -------------------------------------------------------------------------------- 1 | pub const DEFAULT_SCAN_INTERVAL_MS: u64 = 100; 2 | 3 | pub const FS_WATCHER_POLL_INTERVAL_MS: u64 = 100; 4 | 5 | pub const SCANNER_MAX_TIMEOUT_SECS: u64 = 1; 6 | 7 | pub const DEFAULT_NEW_PIDS_CAPACITY: usize = 32; 8 | 9 | pub const DEFAULT_RECURSIVE_DIRS: &[&str] = &["/usr", "/tmp", "/etc", "/home", "/var", "/opt"]; 10 | 11 | pub const LOW_RESOURCE_WATCH_DIRS: &[&str] = &["/etc/ld.so.cache"]; 12 | 13 | pub const DBUS_PROXY_TIMEOUT_SECS: u64 = 5; 14 | pub const DBUS_DEFAULT_SLEEP_MS: u64 = 100; 15 | 16 | pub const UNKNOWN_UID_DISPLAY: &str = "???"; 17 | pub const UNKNOWN_COMMAND: &str = ""; 18 | pub const UID_DISPLAY_WIDTH: usize = 5; 19 | pub const PID_DISPLAY_WIDTH: usize = 8; 20 | 21 | pub const ROOT_UID: u32 = 0; 22 | pub const USER_UID: u32 = 1000; 23 | -------------------------------------------------------------------------------- /src/utils/format.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | pub fn format_duration(duration: Option) -> String { 4 | match duration { 5 | Some(duration) => { 6 | let total_seconds = duration.as_secs(); 7 | let hours = total_seconds / 3600; 8 | let minutes = (total_seconds % 3600) / 60; 9 | let seconds = total_seconds % 60; 10 | let milliseconds = duration.subsec_millis(); 11 | 12 | match (hours, minutes, seconds, milliseconds) { 13 | (0, 0, 0, ms) => format!("{}ms", ms), 14 | (0, 0, s, ms) => format!("{}.{:03}s", s, ms), 15 | (0, m, s, ms) => format!("{}m{:02}.{:03}s", m, s, ms), 16 | (h, m, s, ms) => format!("{}h{:02}m{:02}.{:03}s", h, m, s, ms), 17 | } 18 | } 19 | None => "disabled".to_string(), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/core/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum RsSpyError { 5 | #[error("process monitoring error: {0}")] 6 | Process(#[from] procfs::ProcError), 7 | 8 | #[error("filesystem watching error: {0}")] 9 | Filesystem(String), 10 | 11 | #[error("io error: {0}")] 12 | Io(#[from] std::io::Error), 13 | 14 | #[error("dbus error: {0}")] 15 | DBus(#[from] dbus::Error), 16 | 17 | #[error("configuration error: {0}")] 18 | Config(String), 19 | 20 | #[error("scanner error: {0}")] 21 | Scanner(String), 22 | 23 | #[error("unknown error: {0}")] 24 | Other(String), 25 | } 26 | 27 | pub type Result = std::result::Result; 28 | 29 | impl From for RsSpyError { 30 | fn from(msg: String) -> Self { 31 | RsSpyError::Other(msg) 32 | } 33 | } 34 | 35 | impl From<&str> for RsSpyError { 36 | fn from(msg: &str) -> Self { 37 | RsSpyError::Other(msg.to_string()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build-and-release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions-rust-lang/setup-rust-toolchain@v1 18 | with: 19 | toolchain: stable 20 | 21 | - run: cargo install cross 22 | 23 | - run: cross build --target x86_64-unknown-linux-musl --release 24 | 25 | - name: Create GitHub Release 26 | id: create_release 27 | uses: actions/create-release@v1 28 | with: 29 | tag_name: ${{ github.ref }} 30 | release_name: "Release ${{ github.ref }}" 31 | draft: false 32 | prerelease: false 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Upload Release Asset 37 | uses: actions/upload-release-asset@v1 38 | with: 39 | upload_url: ${{ steps.create_release.outputs.upload_url }} 40 | asset_path: target/x86_64-unknown-linux-musl/release/rspy 41 | asset_name: rspy 42 | asset_content_type: application/octet-stream 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /src/monitoring/process.rs: -------------------------------------------------------------------------------- 1 | use procfs::process::{Process, all_processes}; 2 | use std::collections::HashSet; 3 | 4 | use crate::core::{ 5 | constants::{DEFAULT_NEW_PIDS_CAPACITY, UNKNOWN_COMMAND}, 6 | error::Result, 7 | logger::Logger, 8 | }; 9 | 10 | pub struct ProcessScanner { 11 | seen_pids: HashSet, 12 | #[allow(dead_code)] 13 | current_pids: HashSet, 14 | #[allow(dead_code)] 15 | new_pids: Vec, 16 | } 17 | 18 | impl ProcessScanner { 19 | pub fn new() -> Self { 20 | Self { 21 | seen_pids: HashSet::new(), 22 | current_pids: HashSet::new(), 23 | new_pids: Vec::new(), 24 | } 25 | } 26 | 27 | pub fn scan_processes(&mut self) -> Result { 28 | let processes = all_processes()?; 29 | 30 | self.current_pids.clear(); 31 | self.current_pids.reserve(processes.len()); 32 | self.new_pids.clear(); 33 | self.new_pids.reserve(DEFAULT_NEW_PIDS_CAPACITY); 34 | 35 | for process in processes { 36 | let pid = process.pid(); 37 | self.current_pids.insert(pid); 38 | 39 | if self.seen_pids.insert(pid) { 40 | self.new_pids.push(pid); 41 | } 42 | } 43 | 44 | let mut new_count = 0; 45 | for &pid in &self.new_pids { 46 | match self.process_new_pid(pid) { 47 | Ok(()) => new_count += 1, 48 | Err(e) => { 49 | Logger::debug(format!("failed to process pid {}: {}", pid, e)); 50 | self.seen_pids.remove(&pid); 51 | continue; 52 | } 53 | } 54 | } 55 | 56 | self.seen_pids.retain(|pid| self.current_pids.contains(pid)); 57 | 58 | Ok(new_count) 59 | } 60 | 61 | fn process_new_pid(&self, pid: i32) -> Result<()> { 62 | let process = Process::new(pid)?; 63 | 64 | let cmdline = process 65 | .cmdline() 66 | .unwrap_or_else(|_| vec![UNKNOWN_COMMAND.to_string()]) 67 | .join(" "); 68 | 69 | let status = process.status()?; 70 | let uid = status.ruid; 71 | 72 | Logger::event(Some(uid), pid as u32, &cmdline); 73 | Ok(()) 74 | } 75 | 76 | pub fn get_process_count(&self) -> usize { 77 | self.seen_pids.len() 78 | } 79 | } 80 | 81 | impl Default for ProcessScanner { 82 | fn default() -> Self { 83 | Self::new() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/core/logger.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use colored::*; 3 | use std::io::Write; 4 | 5 | use super::constants::{ 6 | PID_DISPLAY_WIDTH, ROOT_UID, UID_DISPLAY_WIDTH, UNKNOWN_UID_DISPLAY, USER_UID, 7 | }; 8 | 9 | pub struct Logger; 10 | 11 | impl Logger { 12 | pub fn init(debug_level: log::Level) { 13 | let level_filter = match debug_level { 14 | log::Level::Error => log::LevelFilter::Error, 15 | log::Level::Warn => log::LevelFilter::Warn, 16 | log::Level::Info => log::LevelFilter::Info, 17 | log::Level::Debug => log::LevelFilter::Debug, 18 | log::Level::Trace => log::LevelFilter::Trace, 19 | }; 20 | log::set_max_level(level_filter); 21 | } 22 | 23 | fn timestamp() -> colored::ColoredString { 24 | Utc::now().format("%Y-%m-%d %H:%M:%S").to_string().green() 25 | } 26 | 27 | pub fn info>(message: T) { 28 | println!("{} [INFO] - {}", Self::timestamp(), message.into()); 29 | let _ = std::io::stdout().flush(); 30 | } 31 | 32 | pub fn error>(message: T) { 33 | eprintln!("{} [ERROR] - {}", Self::timestamp(), message.into().red()); 34 | let _ = std::io::stderr().flush(); 35 | } 36 | 37 | pub fn event(uid: Option, pid: u32, cmd: &str) { 38 | let uid_display = uid.map_or(UNKNOWN_UID_DISPLAY.to_string(), |u| { 39 | format!("{: message.red(), 51 | Some(USER_UID) => message.blue(), 52 | None => message.yellow(), 53 | _ => message.normal(), 54 | }; 55 | 56 | println!("{} {}", Self::timestamp(), colored_message); 57 | } 58 | 59 | pub fn fs>(message: T) { 60 | let colored_message = message.into().white(); 61 | println!("{} [FS] - {}", Self::timestamp(), colored_message); 62 | } 63 | 64 | pub fn debug>(message: T) { 65 | if log::max_level() >= log::LevelFilter::Debug { 66 | println!("{} [DEBUG] - {}", Self::timestamp(), message.into().cyan()); 67 | } 68 | } 69 | 70 | pub fn dbus_event(_name: &str, pid: u32, cmd: &str) { 71 | Self::dbus_event_with_uid(_name, pid, cmd, None); 72 | } 73 | 74 | pub fn dbus_event_with_uid(_name: &str, pid: u32, cmd: &str, uid: Option) { 75 | let uid_display = uid.map_or(UNKNOWN_UID_DISPLAY.to_string(), |u| { 76 | format!("{: message.red(), 88 | Some(USER_UID) => message.blue(), 89 | None => message.yellow(), 90 | _ => message.normal(), 91 | }; 92 | 93 | println!("{} {}", Self::timestamp(), colored_message); 94 | if let Err(e) = std::io::stdout().flush() { 95 | eprintln!("warning: failed to flush stdout: {}", e); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/monitoring/dbus.rs: -------------------------------------------------------------------------------- 1 | use dbus::blocking::Connection; 2 | use std::collections::HashSet; 3 | use std::time::Duration; 4 | use std::fs; 5 | 6 | use crate::core::{ 7 | constants::{DBUS_DEFAULT_SLEEP_MS, DBUS_PROXY_TIMEOUT_SECS}, 8 | error::Result, 9 | logger::Logger, 10 | }; 11 | 12 | pub struct DBusScanner { 13 | printed_processes: HashSet, 14 | interval: Option, 15 | } 16 | 17 | fn lookup_uid(pid: u32) -> Option { 18 | let status_path = format!("/proc/{}/status", pid); 19 | match fs::read_to_string(&status_path) { 20 | Ok(status_content) => { 21 | for line in status_content.lines() { 22 | if line.starts_with("Uid:") { 23 | return line.split_whitespace().nth(1)?.parse().ok(); 24 | } 25 | } 26 | None 27 | } 28 | Err(_) => None, 29 | } 30 | } 31 | 32 | impl DBusScanner { 33 | pub fn new(interval: Option) -> Self { 34 | DBusScanner { 35 | printed_processes: HashSet::new(), 36 | interval, 37 | } 38 | } 39 | 40 | pub fn is_available() -> bool { 41 | match Connection::new_system() { 42 | Ok(_) => true, 43 | Err(e) => { 44 | Logger::debug(format!("failed to connect to system bus: {}", e)); 45 | match Connection::new_session() { 46 | Ok(_) => true, 47 | Err(e) => { 48 | Logger::debug(format!("failed to connect to session bus: {}", e)); 49 | false 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | pub fn start_listening(&mut self) -> Result<()> { 57 | Logger::debug("attempting to connect to system dbus...".to_string()); 58 | let conn = Connection::new_system().map_err(|e| { 59 | Logger::error(format!("failed to connect to system dbus: {}", e)); 60 | e 61 | })?; 62 | 63 | let sleep_duration = self 64 | .interval 65 | .unwrap_or(Duration::from_millis(DBUS_DEFAULT_SLEEP_MS)); 66 | let proxy_timeout = Duration::from_secs(DBUS_PROXY_TIMEOUT_SECS); 67 | 68 | Logger::debug("creating dbus proxy...".to_string()); 69 | // thanks jkr 70 | let proxy = conn.with_proxy( 71 | "org.freedesktop.systemd1", 72 | "/org/freedesktop/systemd1/unit/_2d_2eslice", 73 | proxy_timeout, 74 | ); 75 | 76 | Logger::debug("starting dbus monitoring loop...".to_string()); 77 | loop { 78 | Logger::debug("polling dbus for processes...".to_string()); 79 | match proxy.method_call("org.freedesktop.systemd1.Slice", "GetProcesses", ()) { 80 | Ok(result) => { 81 | let (processes,): (Vec<(String, u32, String)>,) = result; 82 | Logger::debug(format!("retrieved {} processes from dbus", processes.len())); 83 | 84 | for (name, pid, cmdline) in processes { 85 | if self.printed_processes.insert(pid) { 86 | let uid = lookup_uid(pid); 87 | Logger::dbus_event_with_uid(&name, pid, &cmdline, uid); 88 | } 89 | } 90 | } 91 | Err(e) => { 92 | Logger::error(format!("failed to get processes from dbus: {}", e)); 93 | return Err(e.into()); 94 | } 95 | } 96 | 97 | std::thread::sleep(sleep_duration); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/core/config.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::time::Duration; 3 | 4 | use super::constants::{DEFAULT_RECURSIVE_DIRS, DEFAULT_SCAN_INTERVAL_MS, LOW_RESOURCE_WATCH_DIRS}; 5 | 6 | #[derive(Parser)] 7 | #[command(name = "rspy")] 8 | pub struct Config { 9 | #[arg(short = 'f', long = "print-filesystem-events")] 10 | #[arg(help = "enables printing file system events to stdout (disabled by default)")] 11 | pub print_filesystem_events: bool, 12 | 13 | #[arg(short = 'r', long = "recursive-watch")] 14 | #[arg(help = "list of directories to watch with Inotify recursively")] 15 | pub recursive_watch_dirs: Vec, 16 | 17 | #[arg(short = 'd', long = "direct-watch")] 18 | #[arg(help = "list of directories to watch with inotify directly, not the subdirectories")] 19 | pub direct_watch_dirs: Vec, 20 | 21 | #[arg(long)] 22 | #[arg( 23 | help = "low-resource mode: only monitors /etc and /etc/ld.so.cache with no scan interval" 24 | )] 25 | pub low_resource: bool, 26 | 27 | #[arg(long = "scan-interval")] 28 | #[arg(help = "interval in milliseconds between procfs scans")] 29 | pub scan_interval_ms: Option, 30 | 31 | #[arg(long = "dbus-interval")] 32 | #[arg(help = "interval in milliseconds between DBUS polls")] 33 | pub dbus_interval_ms: Option, 34 | 35 | #[arg(long)] 36 | #[arg(help = "enables debug level logging")] 37 | pub debug: bool, 38 | 39 | #[arg(long)] 40 | #[arg(help = "enable dbus monitoring")] 41 | pub dbus: bool, 42 | 43 | #[arg(long = "dbus-only")] 44 | #[arg(help = "use only dbus monitoring (disables proc scanning + inotify)")] 45 | pub dbus_only: bool, 46 | 47 | #[arg(long = "no-interval")] 48 | #[arg(help = "disable periodic scanning, only trigger scans on filesystem events")] 49 | pub no_interval: bool, 50 | } 51 | 52 | impl Config { 53 | pub fn scan_interval(&self) -> Option { 54 | if self.no_interval { 55 | None 56 | } else { 57 | let interval_ms = self.scan_interval_ms.unwrap_or(DEFAULT_SCAN_INTERVAL_MS); 58 | Some(Duration::from_millis(interval_ms)) 59 | } 60 | } 61 | 62 | pub fn dbus_interval(&self) -> Option { 63 | self.dbus_interval_ms 64 | .map(Duration::from_millis) 65 | .or_else(|| { 66 | Some(Duration::from_millis( 67 | super::constants::DBUS_DEFAULT_SLEEP_MS, 68 | )) 69 | }) 70 | } 71 | 72 | pub fn get_direct_watch_dirs(&self) -> Vec { 73 | let mut dirs = self.direct_watch_dirs.clone(); 74 | if self.low_resource { 75 | dirs.extend(LOW_RESOURCE_WATCH_DIRS.iter().map(|&s| s.to_string())); 76 | } 77 | dirs 78 | } 79 | 80 | pub fn get_recursive_watch_dirs(&self) -> Vec { 81 | if !self.recursive_watch_dirs.is_empty() { 82 | return self.recursive_watch_dirs.clone(); 83 | } 84 | 85 | if !self.low_resource && self.direct_watch_dirs.is_empty() { 86 | DEFAULT_RECURSIVE_DIRS 87 | .iter() 88 | .map(|&s| s.to_string()) 89 | .collect() 90 | } else { 91 | Vec::new() 92 | } 93 | } 94 | 95 | pub fn new() -> Self { 96 | let config = Self::parse(); 97 | config.validate().unwrap_or_else(|e| { 98 | eprintln!("configuration error: {}", e); 99 | std::process::exit(1); 100 | }); 101 | config 102 | } 103 | 104 | fn validate(&self) -> Result<(), String> { 105 | if self.low_resource { 106 | if !self.recursive_watch_dirs.is_empty() { 107 | return Err( 108 | "--low-resource cannot be used with --recursive-watch directories".to_string(), 109 | ); 110 | } 111 | if !self.direct_watch_dirs.is_empty() { 112 | return Err( 113 | "--low-resource cannot be used with --direct-watch directories".to_string(), 114 | ); 115 | } 116 | } 117 | 118 | Ok(()) 119 | } 120 | } 121 | 122 | impl Default for Config { 123 | fn default() -> Self { 124 | Self::new() 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/monitoring/scanner.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::sync::atomic::{AtomicBool, Ordering}; 3 | use std::sync::mpsc::Receiver; 4 | use std::thread; 5 | use std::time::{Duration, Instant}; 6 | 7 | use crate::core::{ 8 | constants::{DEFAULT_SCAN_INTERVAL_MS, SCANNER_MAX_TIMEOUT_SECS}, 9 | logger::Logger, 10 | }; 11 | use crate::monitoring::{dbus::DBusScanner, process::ProcessScanner}; 12 | 13 | pub struct Scanner { 14 | interval: Option, 15 | dbus_interval: Option, 16 | trigger_rx: Option>, 17 | is_active: Arc, 18 | dbus_only: bool, 19 | dbus_scanner: Option, 20 | process_scanner: ProcessScanner, 21 | } 22 | 23 | impl Scanner { 24 | pub fn new( 25 | interval: Option, 26 | trigger_rx: Receiver<()>, 27 | dbus_only: bool, 28 | dbus_enabled: bool, 29 | dbus_interval: Option, 30 | ) -> Self { 31 | let dbus_scanner = if dbus_only || dbus_enabled { 32 | Some(DBusScanner::new(dbus_interval)) 33 | } else { 34 | None 35 | }; 36 | 37 | Self { 38 | interval, 39 | dbus_interval, 40 | trigger_rx: Some(trigger_rx), 41 | is_active: Arc::new(AtomicBool::new(false)), 42 | dbus_only, 43 | dbus_scanner, 44 | process_scanner: ProcessScanner::new(), 45 | } 46 | } 47 | 48 | pub fn start(&mut self) { 49 | self.set_active(true); 50 | 51 | if let Some(mut dbus_scanner) = self.dbus_scanner.take() { 52 | thread::spawn(move || { 53 | if let Err(e) = dbus_scanner.start_listening() { 54 | Logger::error(format!("dbus scanner error: {}", e)); 55 | } 56 | }); 57 | } 58 | 59 | if self.dbus_only { 60 | return; 61 | } 62 | 63 | let is_active = Arc::clone(&self.is_active); 64 | let interval = self.interval; 65 | let dbus_interval = self.dbus_interval; 66 | let _dbus_only = self.dbus_only; 67 | let mut process_scanner = std::mem::take(&mut self.process_scanner); 68 | 69 | if let Some(trigger_rx) = self.trigger_rx.take() { 70 | thread::spawn(move || { 71 | let mut last_process_scan = Instant::now(); 72 | let min_between_scans = 73 | interval.unwrap_or(Duration::from_millis(DEFAULT_SCAN_INTERVAL_MS)); 74 | 75 | // for inactive sleep, use the lowest of the scanning intervals for responsiveness 76 | let inactive_sleep_duration = match (interval, dbus_interval) { 77 | (Some(proc_int), Some(dbus_int)) => std::cmp::min(proc_int, dbus_int), 78 | (Some(proc_int), None) => proc_int, 79 | (None, Some(dbus_int)) => dbus_int, 80 | (None, None) => Duration::from_millis(DEFAULT_SCAN_INTERVAL_MS), 81 | }; 82 | 83 | loop { 84 | if !is_active.load(Ordering::Relaxed) { 85 | thread::sleep(inactive_sleep_duration); 86 | continue; 87 | } 88 | 89 | let now = Instant::now(); 90 | let time_since_last_process = now.duration_since(last_process_scan); 91 | 92 | // calc next process scan time if applicable 93 | let next_process_scan = 94 | interval.map(|interval_duration| last_process_scan + interval_duration); 95 | 96 | let timeout = if let Some(next_scan_time) = next_process_scan { 97 | if now >= next_scan_time { 98 | Duration::from_millis(0) 99 | } else { 100 | std::cmp::min( 101 | next_scan_time.duration_since(now), 102 | Duration::from_secs(SCANNER_MAX_TIMEOUT_SECS), 103 | ) 104 | } 105 | } else { 106 | Duration::from_secs(SCANNER_MAX_TIMEOUT_SECS) 107 | }; 108 | 109 | if let Some(next_scan_time) = next_process_scan { 110 | if now >= next_scan_time { 111 | Logger::debug("starting interval-based process scan...".to_string()); 112 | match process_scanner.scan_processes() { 113 | Ok(new_count) => { 114 | Logger::debug(format!( 115 | "interval scan completed. Found {} new processes. Time since last scan: {:?}", 116 | new_count, time_since_last_process 117 | )); 118 | } 119 | Err(e) => { 120 | Logger::error(format!("interval scan failed: {}", e)); 121 | } 122 | } 123 | last_process_scan = Instant::now(); 124 | continue; 125 | } 126 | } 127 | 128 | match trigger_rx.recv_timeout(timeout) { 129 | Ok(()) => { 130 | if time_since_last_process >= min_between_scans { 131 | // drain any additional pending triggers to avoid backlog 132 | let mut trigger_count = 1; 133 | while trigger_rx.try_recv().is_ok() { 134 | trigger_count += 1; 135 | } 136 | 137 | if trigger_count > 1 { 138 | Logger::debug(format!( 139 | "drained {} pending triggers, starting triggered process scan...", 140 | trigger_count 141 | )); 142 | } else { 143 | Logger::debug( 144 | "trigger received, starting triggered process scan..." 145 | .to_string(), 146 | ); 147 | } 148 | 149 | match process_scanner.scan_processes() { 150 | Ok(new_count) => { 151 | Logger::debug(format!( 152 | "triggered scan completed. Found {} new processes", 153 | new_count 154 | )); 155 | } 156 | Err(e) => { 157 | Logger::error(format!("triggered scan failed: {}", e)); 158 | } 159 | } 160 | last_process_scan = Instant::now(); 161 | } else { 162 | Logger::debug(format!( 163 | "ignoring trigger - only {:?} since last scan (min: {:?})", 164 | time_since_last_process, min_between_scans 165 | )); 166 | } 167 | } 168 | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { 169 | continue; 170 | } 171 | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { 172 | Logger::error("trigger channel disconnected"); 173 | break; 174 | } 175 | } 176 | } 177 | }); 178 | } 179 | } 180 | 181 | pub fn set_active(&self, active: bool) { 182 | self.is_active.store(active, Ordering::Relaxed); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/rspy.rs: -------------------------------------------------------------------------------- 1 | pub mod core; 2 | pub mod monitoring; 3 | pub mod utils; 4 | 5 | use crate::core::config::Config; 6 | use crate::core::error::Result; 7 | use crate::core::logger::Logger; 8 | use crate::monitoring::{dbus::DBusScanner, filesystem::FsWatcher, scanner::Scanner}; 9 | use crate::utils::format::format_duration; 10 | 11 | use colored::*; 12 | use std::io::{self, Write}; 13 | use std::path::PathBuf; 14 | use std::sync::Arc; 15 | use std::sync::atomic::{AtomicBool, Ordering}; 16 | use std::sync::mpsc::{self, Receiver, channel}; 17 | 18 | struct Runtime { 19 | config: Config, 20 | running: Arc, 21 | } 22 | 23 | impl Runtime { 24 | fn new(config: Config) -> Self { 25 | Self { 26 | config, 27 | running: Arc::new(AtomicBool::new(true)), 28 | } 29 | } 30 | 31 | fn display_banner_and_config(&self) -> Result<()> { 32 | let version = env!("CARGO_PKG_VERSION"); 33 | let git_commit_sha = option_env!("GIT_COMMIT_HASH").unwrap_or("unknown"); 34 | 35 | println!( 36 | "rspy - version: {} - commit sha: {}", 37 | version, git_commit_sha 38 | ); 39 | 40 | println!( 41 | "{}", 42 | " 43 | ██▀███ ██████ ██▓███ ▓██ ██▓ 44 | ▓██ ▒ ██▒▒██ ▒ ▓██░ ██▒▒██ ██▒ 45 | ▓██ ░▄█ ▒░ ▓██▄ ▓██░ ██▓▒ ▒██ ██░ 46 | ▒██▀▀█▄ ▒ ██▒▒██▄█▓▒ ▒ ░ ▐██▓░ 47 | ░██▓ ▒██▒▒██████▒▒▒██▒ ░ ░ ░ ██▒▓░ 48 | ░ ▒▓ ░▒▓░▒ ▒▓▒ ▒ ░▒▓▒░ ░ ░ ██▒▒▒ 49 | ░▒ ░ ▒░░ ░▒ ░ ░░▒ ░ ▓██ ░▒░ 50 | ░░ ░ ░ ░ ░ ░░ ▒ ▒ ░░ 51 | ░ ░ ░ ░ 52 | ░ ░ 53 | " 54 | .red() 55 | ); 56 | 57 | self.display_config_info() 58 | } 59 | 60 | fn display_config_info(&self) -> Result<()> { 61 | println!("\n{}", "configuration:".cyan().bold()); 62 | println!( 63 | " print file system events: {}", 64 | if self.config.print_filesystem_events { 65 | "enabled".green() 66 | } else { 67 | "disabled".red() 68 | } 69 | ); 70 | 71 | if self.config.dbus_only { 72 | println!(" process scanning: {}", "dbus only".yellow()); 73 | } else { 74 | match self.config.scan_interval() { 75 | Some(interval) => println!( 76 | " process scanning: {}", 77 | format!("every {} + inotify events", format_duration(Some(interval))).green() 78 | ), 79 | None => println!(" process scanning: {}", "inotify events only".green()), 80 | } 81 | } 82 | 83 | if !self.config.dbus_only { 84 | println!(" watch directories:"); 85 | if !self.config.get_recursive_watch_dirs().is_empty() { 86 | println!( 87 | " recursive: {:?}", 88 | self.config.get_recursive_watch_dirs() 89 | ); 90 | } 91 | if !self.config.get_direct_watch_dirs().is_empty() { 92 | println!(" direct: {:?}", self.config.get_direct_watch_dirs()); 93 | } 94 | } 95 | 96 | println!( 97 | " dbus monitoring: {}", 98 | if self.config.dbus || self.config.dbus_only { 99 | "enabled".green() 100 | } else { 101 | "disabled".red() 102 | } 103 | ); 104 | 105 | if self.config.dbus || self.config.dbus_only { 106 | println!( 107 | " dbus scan interval: {}", 108 | format_duration(self.config.dbus_interval()).cyan() 109 | ); 110 | } 111 | 112 | if !self.config.dbus_only { 113 | println!( 114 | " low-resource mode: {}", 115 | if self.config.low_resource { 116 | "enabled".green() 117 | } else { 118 | "disabled".red() 119 | } 120 | ); 121 | } 122 | 123 | Ok(()) 124 | } 125 | 126 | fn confirm_configuration(&self) -> Result { 127 | loop { 128 | print!("\nproceed with this configuration? [y/n]: "); 129 | if let Err(e) = io::stdout().flush() { 130 | eprintln!("Warning: Failed to flush stdout: {}", e); 131 | } 132 | 133 | let mut input = String::new(); 134 | if io::stdin().read_line(&mut input).is_err() { 135 | println!("failed to read input. exiting..."); 136 | return Ok(false); 137 | } 138 | let input = input.trim().to_lowercase(); 139 | 140 | match input.as_str() { 141 | "y" | "yes" => return Ok(true), 142 | "n" | "no" => { 143 | println!("exiting..."); 144 | return Ok(false); 145 | } 146 | _ => { 147 | println!("invalid input. please enter 'y' or 'n'"); 148 | continue; 149 | } 150 | } 151 | } 152 | } 153 | 154 | fn setup_signal_handler(&self) -> Result<()> { 155 | let running = self.running.clone(); 156 | ctrlc::set_handler(move || { 157 | Logger::info("received interrupt signal, shutting down...".to_string()); 158 | running.store(false, Ordering::SeqCst); 159 | }) 160 | .map_err(|e| format!("error setting Ctrl-C handler: {}", e))?; 161 | Ok(()) 162 | } 163 | 164 | fn run(self) -> Result<()> { 165 | self.display_banner_and_config()?; 166 | 167 | if !self.confirm_configuration()? { 168 | std::process::exit(0); 169 | } 170 | 171 | println!(); 172 | self.setup_signal_handler()?; 173 | 174 | if (self.config.dbus || self.config.dbus_only) && !DBusScanner::is_available() { 175 | Logger::error("dbus is not available on this system. exiting...".to_string()); 176 | std::process::exit(1); 177 | } 178 | 179 | let (tx, rx) = channel(); 180 | let (trigger_tx, trigger_rx) = mpsc::channel(); 181 | 182 | let directories: Vec = self 183 | .config 184 | .get_recursive_watch_dirs() 185 | .iter() 186 | .map(PathBuf::from) 187 | .collect(); 188 | 189 | let mut fs_watcher = if !self.config.dbus_only { 190 | Some(FsWatcher::new( 191 | tx.clone(), 192 | trigger_tx, 193 | directories, 194 | self.config 195 | .get_direct_watch_dirs() 196 | .iter() 197 | .map(PathBuf::from) 198 | .collect(), 199 | self.config.print_filesystem_events, 200 | self.config.low_resource, 201 | self.config.debug, 202 | )?) 203 | } else { 204 | None 205 | }; 206 | 207 | if let Some(watcher) = fs_watcher.as_mut() { 208 | if let Err(e) = watcher.setup_watches() { 209 | Logger::error(format!("failed to setup filesystem watches: {}", e)); 210 | std::process::exit(1); 211 | } 212 | } 213 | 214 | let mut scanner = Scanner::new( 215 | self.config.scan_interval(), 216 | trigger_rx, 217 | self.config.dbus_only, 218 | self.config.dbus, 219 | self.config.dbus_interval(), 220 | ); 221 | 222 | scanner.set_active(true); 223 | scanner.start(); 224 | 225 | if let Some(watcher) = fs_watcher { 226 | if let Err(e) = watcher.start_watching() { 227 | Logger::error(format!("failed to start filesystem watcher: {}", e)); 228 | std::process::exit(1); 229 | } 230 | } 231 | 232 | self.event_loop(rx) 233 | } 234 | 235 | fn event_loop(self, rx: Receiver) -> Result<()> { 236 | loop { 237 | if !self.running.load(Ordering::SeqCst) { 238 | Logger::info("shutting down gracefully...".to_string()); 239 | break; 240 | } 241 | 242 | match rx.recv_timeout(std::time::Duration::from_millis(100)) { 243 | Ok(event) => { 244 | if self.config.print_filesystem_events { 245 | Logger::fs(event); 246 | } 247 | } 248 | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { 249 | continue; 250 | } 251 | Err(e) => { 252 | Logger::error(format!("event channel disconnected: {}", e)); 253 | break; 254 | } 255 | } 256 | } 257 | 258 | Logger::info("rspy terminated".to_string()); 259 | Ok(()) 260 | } 261 | } 262 | 263 | fn main() { 264 | let config = Config::new(); 265 | Logger::init(if config.debug { 266 | log::Level::Debug 267 | } else { 268 | log::Level::Info 269 | }); 270 | 271 | let runtime = Runtime::new(config); 272 | 273 | if let Err(e) = runtime.run() { 274 | Logger::error(format!("runtime error: {}", e)); 275 | std::process::exit(1); 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/monitoring/filesystem.rs: -------------------------------------------------------------------------------- 1 | use libc::{self, IN_ALL_EVENTS, IN_OPEN, inotify_add_watch, inotify_init1}; 2 | use rustc_hash::FxHashMap; 3 | use std::io; 4 | use std::os::unix::io::RawFd; 5 | use std::path::PathBuf; 6 | use std::sync::mpsc::Sender; 7 | use std::thread; 8 | use walkdir::WalkDir; 9 | 10 | use crate::core::{error::Result, logger::Logger}; 11 | 12 | const BUFFER_SIZE: usize = 1024; 13 | 14 | const IN_ACCESS: u32 = 0x00000001; 15 | const IN_MODIFY: u32 = 0x00000002; 16 | const IN_ATTRIB: u32 = 0x00000004; 17 | const IN_CLOSE_WRITE: u32 = 0x00000008; 18 | const IN_CLOSE_NOWRITE: u32 = 0x00000010; 19 | const IN_MOVED_FROM: u32 = 0x00000040; 20 | const IN_MOVED_TO: u32 = 0x00000080; 21 | const IN_CREATE: u32 = 0x00000100; 22 | const IN_DELETE: u32 = 0x00000200; 23 | 24 | #[repr(C)] 25 | struct InotifyEvent { 26 | wd: i32, 27 | mask: u32, 28 | cookie: u32, 29 | len: u32, 30 | name: [u8; 0], 31 | } 32 | 33 | pub struct FsWatcher { 34 | fd: RawFd, 35 | sender: Sender, 36 | trigger_sender: Sender<()>, 37 | recursive_directories: Vec, 38 | direct_directories: Vec, 39 | print_events: bool, 40 | low_resource: bool, 41 | debug: bool, 42 | wd_to_path: FxHashMap, 43 | } 44 | 45 | impl FsWatcher { 46 | fn get_event_string(mask: u32) -> String { 47 | let mut events = Vec::new(); 48 | 49 | if mask & IN_ACCESS != 0 { 50 | events.push("ACCESS"); 51 | } 52 | if mask & IN_MODIFY != 0 { 53 | events.push("MODIFY"); 54 | } 55 | if mask & IN_ATTRIB != 0 { 56 | events.push("ATTRIB"); 57 | } 58 | if mask & IN_CLOSE_WRITE != 0 { 59 | events.push("CLOSE_WRITE"); 60 | } 61 | if mask & IN_CLOSE_NOWRITE != 0 { 62 | events.push("CLOSE_NOWRITE"); 63 | } 64 | if mask & IN_OPEN != 0 { 65 | events.push("OPEN"); 66 | } 67 | if mask & IN_MOVED_FROM != 0 { 68 | events.push("MOVED_FROM"); 69 | } 70 | if mask & IN_MOVED_TO != 0 { 71 | events.push("MOVED_TO"); 72 | } 73 | if mask & IN_CREATE != 0 { 74 | events.push("CREATE"); 75 | } 76 | if mask & IN_DELETE != 0 { 77 | events.push("DELETE"); 78 | } 79 | 80 | events.join("|") 81 | } 82 | 83 | pub fn new( 84 | sender: Sender, 85 | trigger_sender: Sender<()>, 86 | recursive_directories: Vec, 87 | direct_directories: Vec, 88 | print_events: bool, 89 | low_resource: bool, 90 | debug: bool, 91 | ) -> Result { 92 | let fd = unsafe { inotify_init1(0) }; 93 | if fd == -1 { 94 | return Err(io::Error::last_os_error().into()); 95 | } 96 | 97 | Ok(Self { 98 | fd, 99 | sender, 100 | trigger_sender, 101 | recursive_directories, 102 | direct_directories, 103 | print_events, 104 | low_resource, 105 | debug, 106 | wd_to_path: FxHashMap::default(), 107 | }) 108 | } 109 | 110 | pub fn setup_watches(&mut self) -> Result<()> { 111 | let recursive_dirs = self.recursive_directories.clone(); 112 | let direct_dirs = self.direct_directories.clone(); 113 | 114 | for directory in recursive_dirs { 115 | self.add_watch(&directory, true)?; 116 | } 117 | 118 | for directory in direct_dirs { 119 | self.add_watch(&directory, false)?; 120 | } 121 | 122 | Ok(()) 123 | } 124 | 125 | fn add_watch(&mut self, path: &PathBuf, is_recursive: bool) -> Result<()> { 126 | if is_recursive { 127 | for entry in WalkDir::new(path) 128 | .follow_links(true) 129 | .into_iter() 130 | .filter_map(|e| e.ok()) 131 | .filter(|e| e.file_type().is_dir()) 132 | { 133 | self.add_watch_single(&entry.path().to_path_buf())?; 134 | } 135 | } else { 136 | self.add_watch_single(path)?; 137 | } 138 | Ok(()) 139 | } 140 | 141 | fn add_watch_single(&mut self, path: &PathBuf) -> Result<()> { 142 | let path_str = match path.to_str() { 143 | Some(s) => std::ffi::CString::new(s) 144 | .map_err(|e| format!("failed to create CString for path {:?}: {}", path, e))?, 145 | None => { 146 | Logger::error(format!("path contains invalid UTF-8: {:?}", path)); 147 | return Ok(()); 148 | } 149 | }; 150 | 151 | let wd = unsafe { 152 | inotify_add_watch( 153 | self.fd, 154 | path_str.as_ptr(), 155 | if self.low_resource { 156 | IN_OPEN 157 | } else { 158 | IN_ALL_EVENTS 159 | }, 160 | ) 161 | }; 162 | 163 | if wd != -1 { 164 | self.wd_to_path.insert(wd, path.clone()); 165 | if self.debug { 166 | Logger::debug(format!("watching: {:?} (wd={})", path, wd)); 167 | } 168 | } else { 169 | let err = io::Error::last_os_error(); 170 | if self.debug || err.kind() != io::ErrorKind::PermissionDenied { 171 | Logger::error(format!("failed to monitor {:?}: {}", path, err)); 172 | } 173 | } 174 | Ok(()) 175 | } 176 | 177 | pub fn start_watching(self) -> Result<()> { 178 | let sender = self.sender.clone(); 179 | let trigger_sender = self.trigger_sender.clone(); 180 | let wd_to_path = self.wd_to_path.clone(); 181 | let print_events = self.print_events; 182 | let fd = self.fd; 183 | let debug = self.debug; 184 | 185 | thread::spawn(move || { 186 | let _watcher = self; 187 | let mut buffer = [0u8; BUFFER_SIZE]; 188 | 189 | loop { 190 | let read_result = read_events(fd, &mut buffer); 191 | 192 | match read_result { 193 | Ok(read_size) => { 194 | let mut offset = 0; 195 | let mut has_events = false; 196 | 197 | while offset < read_size { 198 | let event = 199 | unsafe { &*(buffer.as_ptr().add(offset) as *const InotifyEvent) }; 200 | 201 | has_events = true; 202 | 203 | if print_events { 204 | if let Some(path) = wd_to_path.get(&event.wd) { 205 | let event_str = format!( 206 | "events: {} on {:?}", 207 | Self::get_event_string(event.mask), 208 | path 209 | ); 210 | if let Err(e) = sender.send(event_str) { 211 | Logger::error(format!("failed to send event: {}", e)); 212 | } 213 | } 214 | } 215 | 216 | if debug { 217 | if let Some(path) = wd_to_path.get(&event.wd) { 218 | Logger::debug(format!( 219 | "inotify event: mask={:x} ({}) on {:?}", 220 | event.mask, 221 | Self::get_event_string(event.mask), 222 | path 223 | )); 224 | } 225 | } 226 | 227 | offset += std::mem::size_of::() + event.len as usize; 228 | } 229 | 230 | // send only one trigger per batch of events to avoid flooding 231 | if has_events { 232 | if let Err(e) = trigger_sender.send(()) { 233 | Logger::error(format!("failed to send trigger: {}", e)); 234 | } else if debug { 235 | Logger::debug( 236 | "sent process scan trigger due to filesystem events" 237 | .to_string(), 238 | ); 239 | } 240 | } 241 | } 242 | Err(e) => { 243 | Logger::error(format!("error reading events: {}", e)); 244 | break; 245 | } 246 | } 247 | } 248 | }); 249 | 250 | Ok(()) 251 | } 252 | } 253 | 254 | fn read_events(fd: RawFd, buffer: &mut [u8]) -> io::Result { 255 | let read_size = 256 | unsafe { libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len()) }; 257 | 258 | if read_size < 0 { 259 | Err(io::Error::last_os_error()) 260 | } else { 261 | Ok(read_size as usize) 262 | } 263 | } 264 | 265 | impl Drop for FsWatcher { 266 | fn drop(&mut self) { 267 | unsafe { 268 | libc::close(self.fd); 269 | } 270 | } 271 | } 272 | --------------------------------------------------------------------------------