diff --git a/Cargo.toml b/Cargo.toml index 1ab4397..7f1e770 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ description = "Detect and Act on unauthorized access of any kind from any source authors = ["jkoontsiii@gmail.com"] license = "MIT" repository = "https://github.com/helloimalemur/watchman" -keywords = ["file-integrity", "filesystem-integirty", "change-detection", "watchman"] +keywords = ["file-integrity", "filesystem-integrity", "change-detection", "watchman"] readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -28,4 +28,6 @@ serde = { version = "1.0.219", features = ["derive"] } tokio = { version = "1.44.1", features = ["rt", "rt-multi-thread", "macros"] } walkdir = "2.5.0" whoami = "1.5.2" -lazy_static = "1.5.0" \ No newline at end of file +lazy_static = "1.5.0" +log = "0.4" +env_logger = "0.11" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a02aa4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Watchman contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/config/linux.rs b/src/config/linux.rs index e7b635c..a86f821 100644 --- a/src/config/linux.rs +++ b/src/config/linux.rs @@ -6,19 +6,19 @@ pub fn configure_linux_service() { .arg("daemon-reload") .output() { - println!("{}", String::from_utf8_lossy(&o.stdout)); + if o.status.success() { log::info!("{}", String::from_utf8_lossy(&o.stdout)); } else { log::error!("{}", String::from_utf8_lossy(&o.stderr)); } if let Ok(o) = process::Command::new("systemctl") .arg("enable") .arg("watchman") .output() { - println!("{}", String::from_utf8_lossy(&o.stdout)); + if o.status.success() { log::info!("{}", String::from_utf8_lossy(&o.stdout)); } else { log::error!("{}", String::from_utf8_lossy(&o.stderr)); } if let Ok(o) = process::Command::new("systemctl") .arg("restart") .arg("watchman") .output() { - println!("{}", String::from_utf8_lossy(&o.stdout)); + if o.status.success() { log::info!("{}", String::from_utf8_lossy(&o.stdout)); } else { log::error!("{}", String::from_utf8_lossy(&o.stderr)); } } } } @@ -27,15 +27,15 @@ pub fn configure_linux_service() { pub fn write_service_file(path: T) { if whoami::username().eq_ignore_ascii_case("root") { if fs::write(path.to_string(), service_file()).is_ok() { - println!( + log::info!( "{}\n ~~~~~~~ SERVICE CONFIG CREATED ~~~~~~~", service_file() ); } else { - println!("{}", "COULD NOT WRITE SERVICE CONFIG TO FILE"); + log::error!("{}", "COULD NOT WRITE SERVICE CONFIG TO FILE"); } } else { - println!( + log::error!( "{}", "PLEASE RUN AS ROOT - COULD NOT WRITE SERVICE CONFIG TO FILE" ); @@ -51,7 +51,7 @@ Description=Watchman Type=simple User=root Group=root -ExecStart=/root/.cargo/bin/watchman +ExecStart=/usr/local/bin/watchman [Install] WantedBy=multi-user.target diff --git a/src/config/macos.rs b/src/config/macos.rs index cf47864..c59c6bb 100644 --- a/src/config/macos.rs +++ b/src/config/macos.rs @@ -7,20 +7,13 @@ pub fn configure_macos_service() { .arg("/Library/LaunchDaemons/com.helloimalemur.watchman.plist") .output() { - println!("{}", String::from_utf8_lossy(&o.stdout)); + if o.status.success() { log::info!("{}", String::from_utf8_lossy(&o.stdout)); } else { log::error!("{}", String::from_utf8_lossy(&o.stderr)); } if let Ok(o) = process::Command::new("launchctl") .arg("start") .arg("com.helloimalemur.watchman") .output() { - println!("{}", String::from_utf8_lossy(&o.stdout)); - // if let Ok(o) = process::Command::new("systemctl") - // .arg("restart") - // .arg("watchman") - // .output() - // { - // println!("{}", String::from_utf8_lossy(&o.stdout)); - // } + if o.status.success() { log::info!("{}", String::from_utf8_lossy(&o.stdout)); } else { log::error!("{}", String::from_utf8_lossy(&o.stderr)); } } } } @@ -28,26 +21,32 @@ pub fn configure_macos_service() { pub fn write_launch_file(path: T) { if whoami::username().eq_ignore_ascii_case("root") { if fs::write(path.to_string(), launch_file()).is_ok() { - println!( + log::info!( "{}\n ~~~~~~~ SERVICE CONFIG CREATED ~~~~~~~", launch_file() ); // sudo chown root:wheel /Library/LaunchDaemons/com.yourname.yourapp.plist // sudo chmod 644 /Library/LaunchDaemons/com.yourname.yourapp.plist - let _ = process::Command::new("chown") + match process::Command::new("chown") .arg("root:wheel") .arg(path.to_string()) - .spawn(); + .output() { + Ok(o) => { if !o.status.success() { log::error!("chown failed: {}", String::from_utf8_lossy(&o.stderr)); } }, + Err(e) => log::error!("failed to execute chown: {}", e), + } - let _ = process::Command::new("chmod") + match process::Command::new("chmod") .arg("644") .arg(path.to_string()) - .spawn(); + .output() { + Ok(o) => { if !o.status.success() { log::error!("chmod failed: {}", String::from_utf8_lossy(&o.stderr)); } }, + Err(e) => log::error!("failed to execute chmod: {}", e), + } } else { - println!("{}", "COULD NOT WRITE SERVICE CONFIG TO FILE"); + log::error!("{}", "COULD NOT WRITE SERVICE CONFIG TO FILE"); } } else { - println!( + log::error!( "{}", "PLEASE RUN AS ROOT - COULD NOT WRITE SERVICE CONFIG TO FILE" ); diff --git a/src/main.rs b/src/main.rs index 01f51fd..4c9fd29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,8 @@ mod util; #[tokio::main] async fn main() { - println!("OS: {}", OS); + env_logger::init(); + log::info!("OS: {}", OS); let arguments = Arguments::parse(); let mut prom = Prometheus::new(); prom.start().await; diff --git a/src/monitors/actions/mod.rs b/src/monitors/actions/mod.rs index cbf4bfd..42b70bc 100644 --- a/src/monitors/actions/mod.rs +++ b/src/monitors/actions/mod.rs @@ -7,8 +7,15 @@ use std::time::Duration; pub async fn reboot_system(settings_map: Config) { let _res = send_discord("System rebooting", settings_map, &"".to_string()).await; - if let Ok(reboot) = Command::new("reboot").output() { - println!("{:#?}", reboot) + match Command::new("reboot").output() { + Ok(reboot) => { + if reboot.status.success() { + log::info!("reboot invoked: {}", String::from_utf8_lossy(&reboot.stdout)); + } else { + log::error!("reboot failed: {}", String::from_utf8_lossy(&reboot.stderr)); + } + } + Err(e) => log::error!("failed to execute reboot: {}", e), } } @@ -16,23 +23,23 @@ pub async fn unmount_encrypted_volumes() { match OS { "linux" => { if let Ok(output) = Command::new("dmsetup").arg("ls").output() { - let encrypted_vols = String::from_utf8(output.stdout.to_vec()).unwrap(); + let encrypted_vols = String::from_utf8(output.stdout.to_vec()).unwrap_or_default(); for ea in encrypted_vols.lines() { let vol_split: Vec<_> = ea.split_ascii_whitespace().collect(); - let luks_vol = vol_split.first().unwrap().to_string(); - // println!("{}", luks_vol); - // println!("{}", command_str); - if let Ok(res) = Command::new("cat").arg("/proc/mounts").output() { - let mount_file_results = String::from_utf8(res.stdout.to_vec()).unwrap(); - for mount in mount_file_results.lines() { - let luks_vol_str = luks_vol.as_str(); - if mount.contains(luks_vol_str) { - let mount_split: Vec<&str> = mount.split_ascii_whitespace().collect(); - let vol_mount_path = mount_split.get(1).unwrap(); - // println!("{}", vol_mount_path); - commit_umount_volume(vol_mount_path); - thread::sleep(Duration::new(1, 0)); - commit_luks_close(luks_vol_str); + if let Some(first) = vol_split.first() { + let luks_vol = first.to_string(); + if let Ok(res) = Command::new("cat").arg("/proc/mounts").output() { + let mount_file_results = String::from_utf8(res.stdout.to_vec()).unwrap_or_default(); + for mount in mount_file_results.lines() { + let luks_vol_str = luks_vol.as_str(); + if mount.contains(luks_vol_str) { + let mount_split: Vec<&str> = mount.split_ascii_whitespace().collect(); + if let Some(vol_mount_path) = mount_split.get(1) { + commit_umount_volume(vol_mount_path); + thread::sleep(Duration::new(1, 0)); + commit_luks_close(luks_vol_str); + } + } } } } @@ -47,7 +54,7 @@ fn commit_umount_volume(vol_mount_path: &str) { match OS { "linux" => { if let Ok(res) = Command::new("umount").arg(vol_mount_path).output() { - println!("{}", String::from_utf8(res.stdout.to_vec()).unwrap()) + if res.status.success() { log::info!("{}", String::from_utf8_lossy(&res.stdout)); } else { log::error!("{}", String::from_utf8_lossy(&res.stderr)); } } } _ => {} @@ -57,13 +64,13 @@ fn commit_umount_volume(vol_mount_path: &str) { fn commit_luks_close(luks_vol: &str) { match OS { "linux" => { - println!("close luks"); + log::info!("close luks"); if let Ok(res) = Command::new("cryptsetup") .arg("luksClose") .arg(luks_vol) .output() { - println!("{}", String::from_utf8(res.stdout.to_vec()).unwrap()) + if res.status.success() { log::info!("{}", String::from_utf8_lossy(&res.stdout)); } else { log::error!("{}", String::from_utf8_lossy(&res.stderr)); } } } _ => {} diff --git a/src/monitors/network.rs b/src/monitors/network.rs index 8782c2f..0e04223 100644 --- a/src/monitors/network.rs +++ b/src/monitors/network.rs @@ -12,7 +12,9 @@ pub struct NETMon { triggered: bool, interfaces: Vec, settings_map: Config, - state: Arc>> + state: Arc>>, + fail_count: u32, + last_reboot: Option, } impl NETMon { @@ -24,7 +26,7 @@ impl NETMon { let moved_sockets = sockets_info.clone(); for si in moved_sockets { match si.protocol_socket_info { - ProtocolSocketInfo::Tcp(tcp_si) => println!( + ProtocolSocketInfo::Tcp(tcp_si) => log::debug!( "TCP {}:{} -> {}:{} {:?} - {}", tcp_si.local_addr, tcp_si.local_port, @@ -33,7 +35,7 @@ impl NETMon { si.associated_pids, tcp_si.state ), - ProtocolSocketInfo::Udp(udp_si) => println!( + ProtocolSocketInfo::Udp(udp_si) => log::debug!( "UDP {}:{} -> *:* {:?}", udp_si.local_addr, udp_si.local_port, si.associated_pids ), @@ -44,21 +46,61 @@ impl NETMon { triggered: false, interfaces: vec![], settings_map, - state: Arc::new(Mutex::new(sockets_info.clone())) + state: Arc::new(Mutex::new(sockets_info.clone())), + fail_count: 0, + last_reboot: None, } } } impl EventMonitor for NETMon { async fn check(&mut self) { - if let Ok(check) = httping::ping("koonts.net", "", "https", 443).await { - self.triggered = !check + let host = self + .settings_map + .get::("net_mon_ping_host") + .unwrap_or_else(|_| "cloudflare.com".to_string()); + let reboot_on_fail = self + .settings_map + .get::("net_mon_reboot_on_failure") + .unwrap_or_else(|_| "false".to_string()) + .eq_ignore_ascii_case("true"); + + match httping::ping(host.as_str(), "", "https", 443).await { + Ok(ok) => { + if !ok { + self.fail_count += 1; + self.triggered = true; + } else { + if self.triggered { log::info!("Network connectivity restored to {}", host); } + self.fail_count = 0; + self.triggered = false; + } + } + Err(e) => { + log::warn!("Network ping error to {}: {}", host, e); + self.fail_count += 1; + self.triggered = true; + } } + if self.triggered { - println!("{} :: ALERT NET", Local::now()); - net_alert(self.settings_map.clone()).await; + log::warn!("{} :: Network connectivity check failed ({} consecutive failures)", Local::now(), self.fail_count); + // Simple backoff: only reboot after 3 consecutive failures and at most once every 10 minutes + if reboot_on_fail && self.fail_count >= 3 { + let now = std::time::SystemTime::now(); + let should_reboot = match self.last_reboot { + None => true, + Some(t) => now.duration_since(t).map(|d| d.as_secs() >= 600).unwrap_or(true), + }; + if should_reboot { + log::error!("Rebooting due to sustained network failure"); + self.last_reboot = Some(now); + net_alert(self.settings_map.clone()).await; + } else { + log::warn!("Reboot on network failure suppressed due to backoff window"); + } + } } - // println!("check net: {}", self.triggered); } } diff --git a/src/prometheus/mod.rs b/src/prometheus/mod.rs index 99ae2b4..d9ca540 100644 --- a/src/prometheus/mod.rs +++ b/src/prometheus/mod.rs @@ -1,8 +1,11 @@ use actix_web::http::header; -use actix_web::{get, web, App, Error, HttpResponse, HttpServer, Responder}; +use actix_web::{get, App, Error, HttpResponse, HttpServer}; use prometheus::{Encoder, TextEncoder}; use std::thread; +use config::Config; +use crate::util::get_config_dir; + pub struct Prometheus {} impl Prometheus { @@ -12,12 +15,35 @@ impl Prometheus { pub async fn start(&self) { let _handle = thread::spawn(|| { let rt = tokio::runtime::Runtime::new(); - let _ = rt.unwrap().block_on( - HttpServer::new(|| App::new().service(metrics)) - .bind(("127.0.0.1", 8323)) - .unwrap() - .run(), - ); + + // Read optional bind config + let mut addr = String::from("127.0.0.1"); + let mut port: u16 = 8323; + let config_path = format!("{}config/settings.toml", get_config_dir()); + if std::path::Path::new(&config_path).exists() { + if let Ok(cfg) = Config::builder() + .add_source(config::File::with_name(&config_path)) + .build() + { + if let Ok(a) = cfg.get::("prom_bind_addr") { addr = a; } + if let Ok(p) = cfg.get::("prom_bind_port") { + if let Ok(n) = p.parse::() { port = n; } else { log::warn!("Invalid prom_bind_port '{}', using default {}", p, port); } + } + } + } + + let server = HttpServer::new(|| App::new().service(metrics)) + .bind((addr.as_str(), port)); + + match server { + Ok(s) => { + log::info!("Prometheus exporter listening on {}:{}", addr, port); + let _ = rt.unwrap().block_on(s.run()); + } + Err(e) => { + log::error!("Failed to bind Prometheus exporter on {}:{}: {}", addr, port, e); + } + } }); // let _ = handle.join(); } @@ -27,18 +53,17 @@ impl Prometheus { async fn metrics() -> Result { let encoder = TextEncoder::new(); - // HTTP_COUNTER.inc(); - // let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["all"]).start_timer(); - let metric_families = prometheus::gather(); let mut buffer = vec![]; - encoder.encode(&metric_families, &mut buffer).unwrap(); - // HTTP_BODY_GAUGE.set(buffer.len() as f64); - let response = String::from_utf8(buffer.clone()).unwrap(); + if let Err(e) = encoder.encode(&metric_families, &mut buffer) { + log::error!("Failed to encode Prometheus metrics: {}", e); + } + let response = String::from_utf8(buffer.clone()).unwrap_or_else(|e| { + log::error!("Failed to build Prometheus metrics body: {}", e); + String::new() + }); buffer.clear(); - // timer.observe_duration(); - Ok(HttpResponse::Ok() .insert_header(header::ContentType(mime::TEXT_PLAIN)) .body(response)) diff --git a/src/util/mod.rs b/src/util/mod.rs index 1ea2cfc..f573bc5 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -113,7 +113,7 @@ pub fn write_default_config(path: T, webhook: String) { ) .is_ok() { - println!( + log::info!( "{}\n ~~~~~~~ UNABLE TO LOCATE CONFIG FILE - DEFAULT CONFIG CREATED ~~~~~~~", default_config().replace("https://discord.com/api/webhooks/", webhook.as_str()) ); diff --git a/src/watchman.rs b/src/watchman.rs index 0edac0d..474bec2 100644 --- a/src/watchman.rs +++ b/src/watchman.rs @@ -47,7 +47,7 @@ impl AppState { pub fn config(&mut self, args: Arguments) { // check for config file if it doesn't exist write default config - println!("Configuring.."); + log::info!("Configuring.."); let cache_dir = get_cache_dir(); let config_dir = get_config_dir(); @@ -61,10 +61,10 @@ impl AppState { if let Some(service_command) = args.service { match service_command.as_str() { "install" => { - println!("Installing.."); + log::info!("Installing.."); setup_service(); if !Path::new(settings_file_path.as_str()).exists() { - println!("Settings.toml does not exist"); + log::warn!("Settings.toml does not exist; writing default and exiting after service install"); crate::util::write_default_config(settings_file_path.clone(), webhook.clone()); exit(0); } @@ -78,26 +78,32 @@ impl AppState { } if !Path::new(settings_file_path.as_str()).exists() { - println!("Settings.toml does not exist"); + log::warn!("Settings.toml does not exist; writing default"); write_default_config(settings_file_path.clone(), webhook); } let blacklist_file_path = format!("{}config/file_mon_blacklist", config_dir); if !Path::new(blacklist_file_path.as_str()).exists() { - println!("file_mon_blacklist does not exist"); + log::warn!("file_mon_blacklist does not exist; creating default"); write_default_blacklist(blacklist_file_path.clone()); } let config = Config::builder(); - let settings = config + let settings = match config .add_source(config::File::with_name(settings_file_path.as_str())) .build() - .expect("Configuration error: unable to parse settings"); + { + Ok(s) => s, + Err(e) => { + log::error!("Configuration error: unable to parse settings: {}", e); + exit(2); + } + }; // initialize monitoring modules let mut monitors: Vec = vec![]; if settings .get::("usb_mon_enabled") - .unwrap() + .unwrap_or("false".to_string()) .eq_ignore_ascii_case("true") { monitors.push(Monitors::USBMon(USBMon::new(settings.clone()))); @@ -111,14 +117,14 @@ impl AppState { // } if settings .get::("burn_file_mon_enabled") - .unwrap() + .unwrap_or("false".to_string()) .eq_ignore_ascii_case("true") { monitors.push(Monitors::SSHBurnMon(SSHBurnMon::new(settings.clone()))); } if settings .get::("fs_mon_enabled") - .unwrap() + .unwrap_or("false".to_string()) .eq_ignore_ascii_case("true") { monitors.push(Monitors::FileChanges(FileChanges::new( @@ -140,20 +146,18 @@ impl AppState { } pub async fn run(&mut self) { - let fs_check_tick = self + // Defaults if not configured or parse fails + let n_fs_check_tick: u64 = self .settings_map - .get::("fs_tick_delay_seconds") - .expect("tick_delay_seconds not found in Settings.toml"); - let n_fs_check_tick = fs_check_tick - .parse::() - .expect("unable to parse fs_tick_delay_seconds"); - let tick = self + .get::("fs_tick_delay_seconds").ok() + .and_then(|v| match v.parse::() { Ok(n) => Some(n), Err(e) => { log::warn!("Invalid fs_tick_delay_seconds '{}': {}. Using default 300.", v, e); None } }) + .unwrap_or(300); + let n_tick: u64 = self .settings_map - .get::("tick_delay_seconds") - .expect("tick_delay_seconds not found in Settings.toml"); - let n_tick = tick - .parse::() - .expect("unable to parse tick_delay_seconds"); + .get::("tick_delay_seconds").ok() + .and_then(|v| match v.parse::() { Ok(n) => Some(n), Err(e) => { log::warn!("Invalid tick_delay_seconds '{}': {}. Using default 5.", v, e); None } }) + .unwrap_or(5); + let mut last = SystemTime::now(); loop { let mut binding = self.monitors.lock(); @@ -161,34 +165,33 @@ impl AppState { for i in bind.iter_mut() { match i { Monitors::USBMon(e) => { - // println!("{:#?}", e); e.check().await; } // Monitors::NetMon(e) => { - // // println!("{:#?}", e); // e.check().await; // } Monitors::SSHBurnMon(e) => { - // println!("{:#?}", e); e.check().await; } Monitors::FileChanges(e) => { - // println!("{:#?}", e); let now = SystemTime::now(); - let dur_since = now.duration_since(last).unwrap(); + let dur_since = match now.duration_since(last) { + Ok(d) => d, + Err(e) => { + log::warn!("SystemTime went backwards: {}", e); + Duration::new(0, 0) + } + }; if dur_since.as_secs() > n_fs_check_tick { - // println!("fs_tick"); last = SystemTime::now(); e.check().await; } } Monitors::NewClients(e) => { - // println!("{:#?}", e); e.check().await; } } tokio::time::sleep(Duration::new(n_tick, 0)).await; - // println!("tick"); } } }