This commit is contained in:
2025-09-17 13:06:18 -04:00
parent fcd377211a
commit bb8b7cc3fd
10 changed files with 201 additions and 101 deletions

View File

@@ -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"
lazy_static = "1.5.0"
log = "0.4"
env_logger = "0.11"

21
LICENSE Normal file
View File

@@ -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.

View File

@@ -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<T: ToString>(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

View File

@@ -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<T: ToString>(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"
);

View File

@@ -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;

View File

@@ -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)); }
}
}
_ => {}

View File

@@ -12,7 +12,9 @@ pub struct NETMon {
triggered: bool,
interfaces: Vec<String>,
settings_map: Config,
state: Arc<Mutex<Vec<SocketInfo>>>
state: Arc<Mutex<Vec<SocketInfo>>>,
fail_count: u32,
last_reboot: Option<std::time::SystemTime>,
}
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::<String>("net_mon_ping_host")
.unwrap_or_else(|_| "cloudflare.com".to_string());
let reboot_on_fail = self
.settings_map
.get::<String>("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);
}
}

View File

@@ -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::<String>("prom_bind_addr") { addr = a; }
if let Ok(p) = cfg.get::<String>("prom_bind_port") {
if let Ok(n) = p.parse::<u16>() { 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<HttpResponse, Error> {
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))

View File

@@ -113,7 +113,7 @@ pub fn write_default_config<T: ToString>(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())
);

View File

@@ -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<Monitors> = vec![];
if settings
.get::<String>("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::<String>("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::<String>("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::<String>("fs_tick_delay_seconds")
.expect("tick_delay_seconds not found in Settings.toml");
let n_fs_check_tick = fs_check_tick
.parse::<u64>()
.expect("unable to parse fs_tick_delay_seconds");
let tick = self
.get::<String>("fs_tick_delay_seconds").ok()
.and_then(|v| match v.parse::<u64>() { 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::<String>("tick_delay_seconds")
.expect("tick_delay_seconds not found in Settings.toml");
let n_tick = tick
.parse::<u64>()
.expect("unable to parse tick_delay_seconds");
.get::<String>("tick_delay_seconds").ok()
.and_then(|v| match v.parse::<u64>() { 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");
}
}
}