first commit
This commit is contained in:
21
src/arguments.rs
Normal file
21
src/arguments.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Subcommand, Deserialize, Serialize, Debug)]
|
||||
pub enum Mode {
|
||||
Standard
|
||||
}
|
||||
|
||||
#[derive(Parser, Deserialize, Serialize, Debug)]
|
||||
#[command(version)]
|
||||
pub struct Arguments {
|
||||
#[clap(subcommand)]
|
||||
pub command: Option<Mode>,
|
||||
#[arg(short, long)]
|
||||
pub config_path: Option<String>,
|
||||
#[arg(short, long)]
|
||||
pub webhook_url: Option<String>,
|
||||
#[arg(short, long)]
|
||||
pub service: Option<String>,
|
||||
|
||||
}
|
||||
59
src/config/linux.rs
Normal file
59
src/config/linux.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::{fs, process};
|
||||
|
||||
pub fn configure_linux_service() {
|
||||
write_service_file("/etc/systemd/system/watchman.service");
|
||||
if let Ok(o) = process::Command::new("systemctl")
|
||||
.arg("daemon-reload")
|
||||
.output()
|
||||
{
|
||||
println!("{}", String::from_utf8_lossy(&o.stdout));
|
||||
if let Ok(o) = process::Command::new("systemctl")
|
||||
.arg("enable")
|
||||
.arg("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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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!(
|
||||
"{}\n ~~~~~~~ SERVICE CONFIG CREATED ~~~~~~~",
|
||||
service_file()
|
||||
);
|
||||
} else {
|
||||
println!("{}", "COULD NOT WRITE SERVICE CONFIG TO FILE");
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
"PLEASE RUN AS ROOT - COULD NOT WRITE SERVICE CONFIG TO FILE"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn service_file() -> &'static str {
|
||||
r#"
|
||||
[Unit]
|
||||
Description=Watchman
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=/root/.cargo/bin/watchman
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"#
|
||||
}
|
||||
85
src/config/macos.rs
Normal file
85
src/config/macos.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::{fs, process};
|
||||
|
||||
pub fn configure_macos_service() {
|
||||
write_launch_file("/Library/LaunchDaemons/com.helloimalemur.watchman.plist");
|
||||
if let Ok(o) = process::Command::new("launchctl")
|
||||
.arg("load")
|
||||
.arg("/Library/LaunchDaemons/com.helloimalemur.watchman.plist")
|
||||
.output()
|
||||
{
|
||||
println!("{}", String::from_utf8_lossy(&o.stdout));
|
||||
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));
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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!(
|
||||
"{}\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")
|
||||
.arg("root:wheel")
|
||||
.arg(path.to_string())
|
||||
.spawn();
|
||||
|
||||
let _ = process::Command::new("chmod")
|
||||
.arg("644")
|
||||
.arg(path.to_string())
|
||||
.spawn();
|
||||
} else {
|
||||
println!("{}", "COULD NOT WRITE SERVICE CONFIG TO FILE");
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
"PLEASE RUN AS ROOT - COULD NOT WRITE SERVICE CONFIG TO FILE"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_file() -> &'static str {
|
||||
r#"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.helloimalemur.watchman</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/watchman</string>
|
||||
</array>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/watchman.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/watchman_errors.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#
|
||||
}
|
||||
21
src/config/mod.rs
Normal file
21
src/config/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::env::consts::OS;
|
||||
use std::process;
|
||||
use crate::config::linux::{configure_linux_service, write_service_file};
|
||||
use crate::config::macos::configure_macos_service;
|
||||
|
||||
mod linux;
|
||||
mod macos;
|
||||
|
||||
pub fn setup_service() {
|
||||
match OS {
|
||||
"linux" => {
|
||||
configure_linux_service();
|
||||
}
|
||||
"macos" => {
|
||||
configure_macos_service();
|
||||
}
|
||||
_ => {
|
||||
println!("WARNING: not implemented for {}", OS);
|
||||
}
|
||||
}
|
||||
}
|
||||
193
src/fortiwatch.rs
Normal file
193
src/fortiwatch.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
|
||||
use crate::monitors::devices::USBMon;
|
||||
use crate::monitors::filechanges::FileChanges;
|
||||
// use crate::monitors::network::NETMon;
|
||||
use crate::monitors::ssh_burn_file::SSHBurnMon;
|
||||
use crate::monitors::clients::NewClientMon;
|
||||
use config::Config;
|
||||
use std::path::Path;
|
||||
use std::process::exit;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::arguments::Arguments;
|
||||
use crate::config::setup_service;
|
||||
use crate::util::{get_cache_dir, get_config_dir, write_default_blacklist, write_default_config};
|
||||
|
||||
pub trait EventMonitor {
|
||||
async fn check(&mut self);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Monitors {
|
||||
USBMon(USBMon),
|
||||
// NetMon(NETMon),
|
||||
SSHBurnMon(SSHBurnMon),
|
||||
FileChanges(FileChanges),
|
||||
NewClients(NewClientMon),
|
||||
}
|
||||
pub struct AppState {
|
||||
pub mon_usb: bool,
|
||||
pub detection_triggered: bool,
|
||||
pub monitors: Arc<Mutex<Vec<Monitors>>>,
|
||||
pub settings_map: Config,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
AppState {
|
||||
mon_usb: true,
|
||||
detection_triggered: false,
|
||||
monitors: Arc::new(Mutex::new(vec![])),
|
||||
settings_map: Config::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&mut self, args: Arguments) {
|
||||
// check for config file if it doesn't exist write default config
|
||||
println!("Configuring..");
|
||||
let cache_dir = get_cache_dir();
|
||||
let config_dir = get_config_dir();
|
||||
|
||||
let mut settings_file_path = format!("{}config/settings.toml", config_dir);
|
||||
|
||||
let mut webhook = String::new();
|
||||
if let Some(wh) = args.webhook_url {
|
||||
webhook = wh
|
||||
}
|
||||
|
||||
if let Some(service_command) = args.service {
|
||||
match service_command.as_str() {
|
||||
"install" => {
|
||||
println!("Installing..");
|
||||
setup_service();
|
||||
if !Path::new(settings_file_path.as_str()).exists() {
|
||||
println!("Settings.toml does not exist");
|
||||
crate::util::write_default_config(settings_file_path.clone(), webhook.clone());
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(config_path) = args.config_path {
|
||||
settings_file_path = config_path.to_string();
|
||||
}
|
||||
|
||||
if !Path::new(settings_file_path.as_str()).exists() {
|
||||
println!("Settings.toml does not exist");
|
||||
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");
|
||||
write_default_blacklist(blacklist_file_path.clone());
|
||||
}
|
||||
let config = Config::builder();
|
||||
let settings = config
|
||||
.add_source(config::File::with_name(settings_file_path.as_str()))
|
||||
.build()
|
||||
.expect("Configuration error: unable to parse settings");
|
||||
|
||||
// initialize monitoring modules
|
||||
let mut monitors: Vec<Monitors> = vec![];
|
||||
if settings
|
||||
.get::<String>("usb_mon_enabled")
|
||||
.unwrap()
|
||||
.eq_ignore_ascii_case("true")
|
||||
{
|
||||
monitors.push(Monitors::USBMon(USBMon::new(settings.clone())));
|
||||
}
|
||||
// if settings
|
||||
// .get::<String>("net_mon_enabled")
|
||||
// .unwrap()
|
||||
// .eq_ignore_ascii_case("true")
|
||||
// {
|
||||
// monitors.push(Monitors::NetMon(NETMon::new(settings.clone())));
|
||||
// }
|
||||
if settings
|
||||
.get::<String>("burn_file_mon_enabled")
|
||||
.unwrap()
|
||||
.eq_ignore_ascii_case("true")
|
||||
{
|
||||
monitors.push(Monitors::SSHBurnMon(SSHBurnMon::new(settings.clone())));
|
||||
}
|
||||
if settings
|
||||
.get::<String>("fs_mon_enabled")
|
||||
.unwrap()
|
||||
.eq_ignore_ascii_case("true")
|
||||
{
|
||||
monitors.push(Monitors::FileChanges(FileChanges::new(
|
||||
settings.clone(),
|
||||
blacklist_file_path,
|
||||
cache_dir,
|
||||
)));
|
||||
}
|
||||
// Enable new client scanning monitor if configured
|
||||
if settings
|
||||
.get::<String>("client_scan_enabled")
|
||||
.unwrap_or("false".to_string())
|
||||
.eq_ignore_ascii_case("true")
|
||||
{
|
||||
monitors.push(Monitors::NewClients(NewClientMon::new(settings.clone())));
|
||||
}
|
||||
self.monitors.clone_from(&Arc::new(Mutex::new(monitors)));
|
||||
self.settings_map.clone_from(&settings)
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) {
|
||||
let fs_check_tick = 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
|
||||
.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");
|
||||
let mut last = SystemTime::now();
|
||||
loop {
|
||||
let mut binding = self.monitors.lock();
|
||||
let bind = binding.as_mut().unwrap();
|
||||
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();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main.rs
Normal file
23
src/main.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use std::env::consts::OS;
|
||||
use clap::Parser;
|
||||
use crate::arguments::Arguments;
|
||||
use crate::fortiwatch::AppState;
|
||||
use crate::prometheus::Prometheus;
|
||||
|
||||
mod fortiwatch;
|
||||
mod monitors;
|
||||
mod prometheus;
|
||||
mod arguments;
|
||||
mod config;
|
||||
mod util;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("OS: {}", OS);
|
||||
let arguments = Arguments::parse();
|
||||
let mut prom = Prometheus::new();
|
||||
prom.start().await;
|
||||
let mut app = AppState::new();
|
||||
app.config(arguments);
|
||||
app.run().await;
|
||||
}
|
||||
82
src/monitors/actions/mod.rs
Normal file
82
src/monitors/actions/mod.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::env::consts::OS;
|
||||
use crate::monitors::notify::send_discord;
|
||||
use config::Config;
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_luks_close(luks_vol: &str) {
|
||||
match OS {
|
||||
"linux" => {
|
||||
println!("close luks");
|
||||
if let Ok(res) = Command::new("cryptsetup")
|
||||
.arg("luksClose")
|
||||
.arg(luks_vol)
|
||||
.output()
|
||||
{
|
||||
println!("{}", String::from_utf8(res.stdout.to_vec()).unwrap())
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// use crate::monitors::actions::unmount_encrypted_volumes;
|
||||
//
|
||||
// #[test]
|
||||
// fn test_unmount_encrypted() {
|
||||
// let rt = tokio::runtime::Runtime::new();
|
||||
// rt.unwrap().block_on(unmount_encrypted_volumes());
|
||||
// }
|
||||
// }
|
||||
296
src/monitors/clients.rs
Normal file
296
src/monitors/clients.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::env::consts::OS;
|
||||
|
||||
use crate::fortiwatch::EventMonitor;
|
||||
use crate::monitors::actions::reboot_system;
|
||||
use crate::monitors::notify::send_discord;
|
||||
use anyhow::anyhow;
|
||||
use config::Config;
|
||||
use lazy_static::lazy_static;
|
||||
use prometheus::{register_int_counter, IntCounter};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref SCANS_TOTAL: IntCounter = register_int_counter!(
|
||||
"watchman_scans_total",
|
||||
"Total client scans run"
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref FINDINGS_TOTAL: IntCounter = register_int_counter!(
|
||||
"watchman_findings_total",
|
||||
"Total scan findings"
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref ACTIONS_TOTAL: IntCounter = register_int_counter!(
|
||||
"watchman_actions_total",
|
||||
"Total actions taken on findings"
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref SCANNER_ERRORS_TOTAL: IntCounter = register_int_counter!(
|
||||
"watchman_scanner_errors_total",
|
||||
"Total scanner errors"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewClientMon {
|
||||
settings_map: Config,
|
||||
known_clients: HashSet<String>,
|
||||
last_seen: HashMap<String, SystemTime>,
|
||||
debounce_secs: u64,
|
||||
}
|
||||
|
||||
impl NewClientMon {
|
||||
pub fn new(settings_map: Config) -> Self {
|
||||
NewClientMon {
|
||||
settings_map,
|
||||
known_clients: HashSet::new(),
|
||||
last_seen: HashMap::new(),
|
||||
debounce_secs: 600, // 10 minutes debounce
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventMonitor for NewClientMon {
|
||||
async fn check(&mut self) {
|
||||
let discovery_method = self
|
||||
.settings_map
|
||||
.get::<String>("client_discovery_method")
|
||||
.unwrap_or("arp".to_string());
|
||||
let exclusions: Vec<String> = self
|
||||
.settings_map
|
||||
.get::<Vec<String>>("client_scan_exclusions")
|
||||
.unwrap_or(vec![]);
|
||||
|
||||
let cur_clients = match discovery_method.as_str() {
|
||||
"arp" => discover_via_arp(),
|
||||
// Future: add ping sweep
|
||||
_ => discover_via_arp(),
|
||||
};
|
||||
|
||||
if cur_clients.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = SystemTime::now();
|
||||
for ip in cur_clients {
|
||||
if exclusions.iter().any(|e| e == &ip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let recently_seen = self
|
||||
.last_seen
|
||||
.get(&ip)
|
||||
.and_then(|t| now.duration_since(*t).ok())
|
||||
.map(|d| d.as_secs() < self.debounce_secs)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !self.known_clients.contains(&ip) || !recently_seen {
|
||||
self.known_clients.insert(ip.clone());
|
||||
self.last_seen.insert(ip.clone(), now);
|
||||
|
||||
// Trigger scan on add if enabled
|
||||
let scan_on_add = self
|
||||
.settings_map
|
||||
.get::<String>("scan_on_add")
|
||||
.unwrap_or("true".to_string())
|
||||
.eq_ignore_ascii_case("true");
|
||||
if scan_on_add {
|
||||
if let Err(e) = scan_and_evaluate(ip.clone(), self.settings_map.clone()).await {
|
||||
eprintln!("scan error: {:?}", e);
|
||||
SCANNER_ERRORS_TOTAL.inc();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_via_arp() -> Vec<String> {
|
||||
let mut ips: Vec<String> = vec![];
|
||||
match OS {
|
||||
"windows" => {
|
||||
if let Ok(out) = Command::new("arp").arg("-a").output() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
for line in s.lines() {
|
||||
if let Some(ip) = extract_ip(line) {
|
||||
if !ips.contains(&ip) {
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"macos" => {
|
||||
if let Ok(out) = Command::new("arp").arg("-an").output() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
for line in s.lines() {
|
||||
if let Some(ip) = extract_ip(line) {
|
||||
if !ips.contains(&ip) {
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Ok(out) = Command::new("arp").arg("-a").output() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
for line in s.lines() {
|
||||
if let Some(ip) = extract_ip(line) {
|
||||
if !ips.contains(&ip) {
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Ok(out) = Command::new("ip").arg("neigh").output() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
for line in s.lines() {
|
||||
if let Some(ip) = extract_ip(line) {
|
||||
if !ips.contains(&ip) {
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Ok(out) = Command::new("arp").arg("-an").output() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
for line in s.lines() {
|
||||
if let Some(ip) = extract_ip(line) {
|
||||
if !ips.contains(&ip) {
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ips
|
||||
}
|
||||
|
||||
fn extract_ip(line: &str) -> Option<String> {
|
||||
// naive parse for dotted IPv4
|
||||
let mut cur = String::new();
|
||||
let mut found = None;
|
||||
for tok in line.split(|c: char| c.is_whitespace() || c == '(' || c == ')' ) {
|
||||
if tok.chars().filter(|c| *c == '.').count() == 3 {
|
||||
// simple check
|
||||
cur = tok.to_string();
|
||||
if cur.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
found = Some(cur.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
async fn scan_and_evaluate(ip: String, settings_map: Config) -> Result<(), anyhow::Error> {
|
||||
SCANS_TOTAL.inc();
|
||||
let nmap_path = settings_map
|
||||
.get::<String>("nmap_path")
|
||||
.unwrap_or("nmap".to_string());
|
||||
let nmap_profile = settings_map
|
||||
.get::<String>("nmap_profile")
|
||||
.unwrap_or("-sV -T4".to_string());
|
||||
|
||||
// Build command: nmap [profile] -oX - <ip> without using a shell for cross-platform compatibility
|
||||
let mut cmd = Command::new(nmap_path);
|
||||
for arg in nmap_profile.split_whitespace() {
|
||||
if !arg.is_empty() { cmd.arg(arg); }
|
||||
}
|
||||
cmd.arg("-oX").arg("-").arg(&ip);
|
||||
let output = cmd.output()?;
|
||||
if !output.status.success() {
|
||||
SCANNER_ERRORS_TOTAL.inc();
|
||||
return Err(anyhow!("nmap failed for {}", ip));
|
||||
}
|
||||
let xml = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let open_ports = parse_open_ports_from_nmap_xml(&xml);
|
||||
|
||||
let critical_ports: Vec<String> = settings_map
|
||||
.get::<Vec<String>>("findings_critical_ports")
|
||||
.unwrap_or(vec!["23".to_string(), "2323".to_string(), "3389".to_string(), "5900".to_string()]);
|
||||
|
||||
let mut critical_hits: Vec<String> = vec![];
|
||||
for p in &open_ports {
|
||||
if critical_ports.iter().any(|c| c == p) {
|
||||
critical_hits.push(p.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let notify = settings_map
|
||||
.get::<String>("notify_on_findings")
|
||||
.unwrap_or("false".to_string())
|
||||
.eq_ignore_ascii_case("true");
|
||||
|
||||
if !open_ports.is_empty() {
|
||||
FINDINGS_TOTAL.inc();
|
||||
let summary = format!(
|
||||
"New client {} scanned. Open ports: {}. Critical: {}",
|
||||
ip,
|
||||
open_ports.join(","),
|
||||
if critical_hits.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
critical_hits.join(",")
|
||||
}
|
||||
);
|
||||
if notify {
|
||||
let _ = send_discord("Client scan findings", settings_map.clone(), &summary).await;
|
||||
}
|
||||
|
||||
let action = settings_map
|
||||
.get::<String>("action_on_findings")
|
||||
.unwrap_or("none".to_string())
|
||||
.to_lowercase();
|
||||
match action.as_str() {
|
||||
"reboot" => {
|
||||
ACTIONS_TOTAL.inc();
|
||||
reboot_system(settings_map.clone()).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_open_ports_from_nmap_xml(xml: &str) -> Vec<String> {
|
||||
// Very naive XML parsing: look for <port portid="NNN"> ... <state state="open" .../>
|
||||
let mut ports: Vec<String> = vec![];
|
||||
let bytes = xml.as_bytes();
|
||||
let mut i = 0;
|
||||
while let Some(port_idx) = find_sub(bytes, i, b"<port ") {
|
||||
if let Some(portid_idx) = find_sub(bytes, port_idx, b"portid=\"") {
|
||||
let start = portid_idx + 8;
|
||||
if let Some(end) = find_quote(bytes, start) {
|
||||
let portid = &xml[start..end];
|
||||
// search for state="open" before closing port tag
|
||||
if let Some(state_idx) = find_sub(bytes, portid_idx, b"state=\"open\"") {
|
||||
if let Some(close_idx) = find_sub(bytes, portid_idx, b"</port>") {
|
||||
if state_idx < close_idx {
|
||||
ports.push(portid.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i = port_idx + 6;
|
||||
}
|
||||
ports
|
||||
}
|
||||
|
||||
fn find_sub(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
|
||||
hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
|
||||
}
|
||||
|
||||
fn find_quote(hay: &[u8], from: usize) -> Option<usize> {
|
||||
for i in from..hay.len() {
|
||||
if hay[i] == b'"' { return Some(i); }
|
||||
}
|
||||
None
|
||||
}
|
||||
182
src/monitors/devices.rs
Normal file
182
src/monitors/devices.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use std::env::consts::OS;
|
||||
use crate::fortiwatch::EventMonitor;
|
||||
use crate::monitors::actions::reboot_system;
|
||||
use crate::monitors::notify::send_discord;
|
||||
use chrono::Local;
|
||||
use config::Config;
|
||||
use std::process::{exit, Command};
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct USBMon {
|
||||
triggered: bool,
|
||||
devices: Vec<String>,
|
||||
total_devices: usize,
|
||||
last_check: usize,
|
||||
settings_map: Config,
|
||||
}
|
||||
|
||||
impl USBMon {
|
||||
pub fn new(settings_map: Config) -> Self {
|
||||
USBMon {
|
||||
triggered: false,
|
||||
devices: vec![],
|
||||
total_devices: 0,
|
||||
last_check: 0,
|
||||
settings_map,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventMonitor for USBMon {
|
||||
async fn check(&mut self) {
|
||||
let mut dev_change = String::new();
|
||||
let mut new_devices: Vec<String> = vec![];
|
||||
let n = get_usb_devices_physical().await;
|
||||
n.iter().for_each(|r| new_devices.push(r.to_string()));
|
||||
let d = get_usb_devices().await;
|
||||
d.iter().for_each(|r| new_devices.push(r.to_string()));
|
||||
|
||||
if self.last_check != 0 && self.last_check != new_devices.len() {
|
||||
self.total_devices = self.devices.len();
|
||||
|
||||
match self.last_check < new_devices.len() {
|
||||
true => {
|
||||
for entry in new_devices.iter() {
|
||||
if !self.devices.contains(&entry) {
|
||||
dev_change = entry.clone();
|
||||
}
|
||||
}
|
||||
self.triggered = true;
|
||||
println!(
|
||||
"{} :: Total USB devices INCREASED: {} :: {}",
|
||||
Local::now(),
|
||||
self.total_devices,
|
||||
&dev_change
|
||||
);
|
||||
}
|
||||
false => {
|
||||
for entry in self.devices.iter() {
|
||||
if !new_devices.contains(&entry) {
|
||||
dev_change = entry.clone();
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{} :: Total USB devices DECREASED: {} :: {}",
|
||||
Local::now(),
|
||||
self.total_devices,
|
||||
&dev_change
|
||||
);
|
||||
}
|
||||
}
|
||||
self.devices.clone_from(&new_devices);
|
||||
self.last_check = self.total_devices;
|
||||
} else if self.last_check == 0 {
|
||||
self.devices.clone_from(&new_devices);
|
||||
self.total_devices = new_devices.len();
|
||||
self.last_check = self.total_devices;
|
||||
println!("Starting..");
|
||||
println!(
|
||||
"{} :: Total USB devices: {}",
|
||||
Local::now(),
|
||||
self.total_devices
|
||||
);
|
||||
}
|
||||
|
||||
// println!(
|
||||
// "check usb: {}, count: {}",
|
||||
// self.triggered, self.total_devices
|
||||
// );
|
||||
|
||||
if self.triggered {
|
||||
println!("ALERT USB");
|
||||
usb_triggered(self.settings_map.clone(), &dev_change).await;
|
||||
println!(
|
||||
"{} :: USB count: {} :: {}",
|
||||
Local::now(),
|
||||
self.total_devices,
|
||||
&dev_change
|
||||
);
|
||||
self.triggered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_usb_devices() -> Vec<String> {
|
||||
let mut devices: Vec<String> = vec![];
|
||||
match OS {
|
||||
"linux" => {
|
||||
if let Ok(res) = Command::new("lsusb").output() {
|
||||
if let Ok(result) = String::from_utf8(res.stdout.to_vec()) {
|
||||
for r in result.lines() {
|
||||
if !r.trim().is_empty() {
|
||||
devices.push(r.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"macos" => {
|
||||
if let Ok(res) = Command::new("system_profiler").arg("SPUSBDataType").output() {
|
||||
if let Ok(result) = String::from_utf8(res.stdout.to_vec()) {
|
||||
for r in result.lines() {
|
||||
if !r.trim().is_empty() {
|
||||
devices.push(r.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unsupported platform (e.g., Windows): return empty list for safety
|
||||
}
|
||||
}
|
||||
devices
|
||||
}
|
||||
|
||||
async fn get_usb_devices_physical() -> Vec<String> {
|
||||
let mut devices: Vec<String> = vec![];
|
||||
match OS {
|
||||
"linux" => {
|
||||
if let Ok(contents) = fs::read_to_string("/proc/bus/input/devices") {
|
||||
for line in contents.lines() {
|
||||
if line.trim_start().starts_with("S:") {
|
||||
devices.push(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"macos" => {
|
||||
if let Ok(res) = Command::new("ioreg").args(["-n", "IOHIDSystem", "-r"]).output() {
|
||||
if let Ok(result) = String::from_utf8(res.stdout.to_vec()) {
|
||||
for r in result.lines() {
|
||||
if !r.trim().is_empty() {
|
||||
devices.push(r.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unsupported platform (e.g., Windows): return empty list for safety
|
||||
}
|
||||
}
|
||||
devices
|
||||
}
|
||||
|
||||
async fn usb_triggered(settings_map: Config, dev_change: &String) {
|
||||
if settings_map
|
||||
.get::<String>("reboot_on_increase_of_usb_devices")
|
||||
.unwrap_or("false".to_string())
|
||||
.eq_ignore_ascii_case("true")
|
||||
{
|
||||
reboot_system(settings_map.clone()).await;
|
||||
}
|
||||
if settings_map
|
||||
.get::<String>("notify_on_increase_of_usb_devices")
|
||||
.unwrap_or("false".to_string())
|
||||
.eq_ignore_ascii_case("true")
|
||||
{
|
||||
let _ = send_discord("USB triggered", settings_map.clone(), &dev_change).await;
|
||||
}
|
||||
}
|
||||
327
src/monitors/filechanges.rs
Normal file
327
src/monitors/filechanges.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
use crate::fortiwatch::EventMonitor;
|
||||
use crate::monitors::notify::send_discord;
|
||||
use chrono::Local;
|
||||
use config::Config;
|
||||
use filesystem_hashing::hasher::HashType;
|
||||
use filesystem_hashing::snapshot::Snapshot;
|
||||
use filesystem_hashing::{compare_snapshots, create_snapshot};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{env, fs};
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug)]
|
||||
pub struct FileChanges {
|
||||
triggered: bool,
|
||||
step: u16,
|
||||
monitored_directories: Vec<String>,
|
||||
snapshots: Vec<Snapshot>,
|
||||
hash_type: HashType,
|
||||
settings_map: Config,
|
||||
black_list: Vec<String>,
|
||||
app_cache_path: String,
|
||||
}
|
||||
|
||||
impl FileChanges {
|
||||
pub fn new(settings_map: Config, blacklist_file_path: String, app_cache_path: String) -> Self {
|
||||
let mut file_changes = FileChanges {
|
||||
triggered: false,
|
||||
step: 0,
|
||||
monitored_directories: load_directories(settings_map.clone()),
|
||||
snapshots: vec![],
|
||||
hash_type: get_hash_type(settings_map.clone()),
|
||||
settings_map,
|
||||
black_list: load_blacklist(blacklist_file_path),
|
||||
app_cache_path,
|
||||
};
|
||||
|
||||
// load and push blacklisted directories
|
||||
println!("Blacklisted: {:?}", file_changes.black_list);
|
||||
|
||||
// file_changes.load_state();
|
||||
|
||||
if file_changes.snapshots.is_empty() {
|
||||
if let Ok(b) = file_changes
|
||||
.settings_map
|
||||
.get::<bool>("fs_mon_path_variable")
|
||||
{
|
||||
if b {
|
||||
let path = env::var("PATH").unwrap_or_else(|_| String::new());
|
||||
let split = path.split(':').collect::<Vec<&str>>();
|
||||
for s in split {
|
||||
if !file_changes.black_list.contains(&s.to_string()) {
|
||||
if let Ok(snapshot) = create_snapshot(
|
||||
s,
|
||||
HashType::BLAKE3,
|
||||
file_changes.black_list.clone(),
|
||||
false,
|
||||
) {
|
||||
file_changes.snapshots.push(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for dir in &file_changes.monitored_directories {
|
||||
if !file_changes.black_list.contains(dir) {
|
||||
if let Ok(snapshot) = create_snapshot(
|
||||
dir.as_str(),
|
||||
HashType::BLAKE3,
|
||||
file_changes.black_list.clone(),
|
||||
false,
|
||||
) {
|
||||
file_changes.snapshots.push(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut count = 0;
|
||||
println!("File Count:");
|
||||
file_changes.snapshots.iter().for_each(|s| {
|
||||
let s_len = s.file_hashes.lock().unwrap().len();
|
||||
println!("{} ---- {}", s_len, s.root_path);
|
||||
count += s_len
|
||||
});
|
||||
// println!("{:#?}", file_changes.snapshots);
|
||||
|
||||
let message = format!(
|
||||
"{} :: Filesystem Snapshot Creation Successful\n\nTotal files: {}\n",
|
||||
Local::now(),
|
||||
count
|
||||
);
|
||||
println!("{}", message);
|
||||
}
|
||||
|
||||
file_changes.save_state();
|
||||
file_changes
|
||||
}
|
||||
|
||||
fn save_state(&mut self) {
|
||||
let snapshots_path = format!("{}snapshots/", self.app_cache_path);
|
||||
// println!("{}", snapshots_path);
|
||||
let _ = fs::create_dir_all(Path::new(snapshots_path.as_str()));
|
||||
self.snapshots.iter().for_each(|snapshot: &Snapshot| {
|
||||
let root_path_hash = blake3::hash(snapshot.root_path.as_bytes()).to_string();
|
||||
// println!("Exporting: {}", root_path_hash);
|
||||
let path = format!("{}/snapshots/{}", self.app_cache_path, root_path_hash);
|
||||
// println!("{:?}", snapshot);
|
||||
let sn = Snapshot {
|
||||
file_hashes: Arc::new(Mutex::new(snapshot.file_hashes.lock().unwrap().clone())),
|
||||
black_list: snapshot.black_list.clone(),
|
||||
root_path: snapshot.root_path.clone(),
|
||||
hash_type: HashType::BLAKE3,
|
||||
uuid: snapshot.uuid.clone(),
|
||||
date_created: snapshot.date_created.clone(),
|
||||
};
|
||||
|
||||
if filesystem_hashing::export_snapshot(sn, path, true, false).is_err() {
|
||||
println!("WARNING: could not save state")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn load_state(&mut self) {
|
||||
let snapshots_path = format!("{}snapshots/", self.app_cache_path);
|
||||
let mut snapshots: Vec<Snapshot> = vec![];
|
||||
let mut count = 0;
|
||||
if Path::new(snapshots_path.as_str()).exists() {
|
||||
let dir_vec = walkdir::WalkDir::new(snapshots_path)
|
||||
.contents_first(true)
|
||||
.into_iter()
|
||||
.map(|e| e.unwrap().path().to_str().unwrap().to_string())
|
||||
.filter(|a| Path::new(a).is_file())
|
||||
.collect::<Vec<String>>();
|
||||
// println!("{:?}", dir_vec);
|
||||
dir_vec.iter().for_each(|dir| {
|
||||
println!("{}", dir);
|
||||
let import = filesystem_hashing::import_snapshot(dir.to_string(), false).unwrap();
|
||||
// println!("{:?}", import);
|
||||
count += import.file_hashes.lock().unwrap().len();
|
||||
snapshots.push(import)
|
||||
});
|
||||
|
||||
self.snapshots.clone_from(&snapshots);
|
||||
println!("State Loaded..{} files", count);
|
||||
// drop(snapshots)
|
||||
// println!("{:?}", dir_vec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventMonitor for FileChanges {
|
||||
async fn check(&mut self) {
|
||||
match compare_all_snapshots(self, self.settings_map.clone(), self.black_list.clone()).await
|
||||
{
|
||||
None => {
|
||||
println!("...");
|
||||
}
|
||||
Some(e) => match e.0 {
|
||||
SnapshotChangeType::None => {
|
||||
// let message = format!("{} :: File System Unchanged",Local::now());
|
||||
println!(".. no changes ..");
|
||||
}
|
||||
SnapshotChangeType::Created => {
|
||||
// println!("{} :: File Created Alert!\n{:#?}", Local::now(), e.1);
|
||||
let message = format!(
|
||||
"{} :: File Creation Detected: {:?}",
|
||||
Local::now(),
|
||||
e.1.created
|
||||
);
|
||||
fs_changes_alert(message, self.settings_map.clone()).await
|
||||
}
|
||||
SnapshotChangeType::Deleted => {
|
||||
// println!("{} :: File Deleted Alert!\n{:#?}", Local::now(), e.1);
|
||||
let message = format!(
|
||||
"{} :: File Deletion Detected: {:?}",
|
||||
Local::now(),
|
||||
e.1.deleted
|
||||
);
|
||||
fs_changes_alert(message, self.settings_map.clone()).await
|
||||
}
|
||||
SnapshotChangeType::Changed => {
|
||||
// println!("{} :: File Change Alert!\n{:#?}", Local::now(), e.1);
|
||||
let message = format!(
|
||||
"{} :: File Change Detected: {:?}",
|
||||
Local::now(),
|
||||
e.1.changed
|
||||
);
|
||||
fs_changes_alert(message, self.settings_map.clone()).await
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
self.triggered = false;
|
||||
self.save_state()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_directories(settings_map: Config) -> Vec<String> {
|
||||
let mut dirs: Vec<String> = vec![];
|
||||
let mon_dirs = settings_map.get::<Vec<String>>("fs_mon_dir").unwrap();
|
||||
for i in mon_dirs.iter() {
|
||||
if i.contains('$') {
|
||||
let env_var = i.replace('$', "");
|
||||
let env_ret = env::var(env_var).unwrap();
|
||||
let split = env_ret.split(':').collect::<Vec<&str>>();
|
||||
split.iter().for_each(|e| dirs.push(e.to_string()))
|
||||
} else {
|
||||
dirs.push(i.to_string())
|
||||
}
|
||||
}
|
||||
println!("Monitoring Directories: {:#?}", dirs);
|
||||
dirs
|
||||
}
|
||||
|
||||
fn get_hash_type(settings_map: Config) -> HashType {
|
||||
match settings_map
|
||||
.get::<String>("fs_mon_hash_type")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
{
|
||||
"blake3" => HashType::BLAKE3,
|
||||
"SHA3" => HashType::SHA3,
|
||||
"MD5" => HashType::MD5,
|
||||
_ => HashType::BLAKE3,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_blacklist(blacklist_file_path: String) -> Vec<String> {
|
||||
let mut black_list: Vec<String> = vec![];
|
||||
if let Ok(file) = fs::read_to_string(Path::new(&blacklist_file_path)) {
|
||||
for line in file.lines() {
|
||||
if !line.is_empty() {
|
||||
black_list.push(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
black_list
|
||||
}
|
||||
|
||||
enum SnapshotChangeType {
|
||||
None,
|
||||
Created,
|
||||
Deleted,
|
||||
Changed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SnapshotCompareResult {
|
||||
pub created: Vec<String>,
|
||||
pub deleted: Vec<String>,
|
||||
pub changed: Vec<String>,
|
||||
}
|
||||
|
||||
async fn compare_all_snapshots(
|
||||
file_changes: &mut FileChanges,
|
||||
_settings_map: Config,
|
||||
black_list: Vec<String>,
|
||||
) -> Option<(SnapshotChangeType, SnapshotCompareResult)> {
|
||||
let mut created: Vec<String> = vec![];
|
||||
let mut deleted: Vec<String> = vec![];
|
||||
let mut changed: Vec<String> = vec![];
|
||||
let mut to_remove: Vec<usize> = vec![];
|
||||
let mut new_sn: Vec<Snapshot> = vec![];
|
||||
|
||||
for (ind, i) in file_changes.snapshots.iter().enumerate() {
|
||||
// println!("{:#?}", black_list);
|
||||
if let Ok(rehash) =
|
||||
Snapshot::new(i.root_path.as_ref(), i.hash_type, black_list.clone(), false)
|
||||
{
|
||||
if let Some(res) = compare_snapshots(i.clone(), rehash.clone(), false) {
|
||||
// println!("{}", i.root_path);
|
||||
for c in res.1.created {
|
||||
created.push(c)
|
||||
}
|
||||
for d in res.1.deleted {
|
||||
deleted.push(d)
|
||||
}
|
||||
for ch in res.1.changed {
|
||||
changed.push(ch)
|
||||
}
|
||||
|
||||
to_remove.push(ind);
|
||||
|
||||
new_sn.push(rehash.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file_changes.snapshots.clear();
|
||||
file_changes.snapshots = new_sn;
|
||||
|
||||
let mut return_type = SnapshotChangeType::None;
|
||||
if !created.is_empty() {
|
||||
return_type = SnapshotChangeType::Created;
|
||||
}
|
||||
if !deleted.is_empty() {
|
||||
return_type = SnapshotChangeType::Deleted;
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
return_type = SnapshotChangeType::Changed;
|
||||
}
|
||||
|
||||
Some((
|
||||
return_type,
|
||||
SnapshotCompareResult {
|
||||
created,
|
||||
deleted,
|
||||
changed,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn fs_changes_alert(message: String, settings_map: Config) {
|
||||
println!("{}", message);
|
||||
let _ = send_discord(message.as_str(), settings_map, &"".to_string()).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::monitors::filechanges::load_blacklist;
|
||||
|
||||
#[test]
|
||||
fn test_load_blacklist() {
|
||||
let x = load_blacklist("".to_string());
|
||||
println!("{:#?}", x);
|
||||
}
|
||||
}
|
||||
7
src/monitors/mod.rs
Normal file
7
src/monitors/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod actions;
|
||||
pub mod devices;
|
||||
pub mod filechanges;
|
||||
pub mod network;
|
||||
pub mod notify;
|
||||
pub mod ssh_burn_file;
|
||||
pub mod clients;
|
||||
67
src/monitors/network.rs
Normal file
67
src/monitors/network.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
use crate::fortiwatch::EventMonitor;
|
||||
use crate::monitors::actions::reboot_system;
|
||||
use chrono::Local;
|
||||
use config::Config;
|
||||
use netstat::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, SocketInfo};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug)]
|
||||
pub struct NETMon {
|
||||
triggered: bool,
|
||||
interfaces: Vec<String>,
|
||||
settings_map: Config,
|
||||
state: Arc<Mutex<Vec<SocketInfo>>>
|
||||
}
|
||||
|
||||
impl NETMon {
|
||||
pub fn new(settings_map: Config) -> Self {
|
||||
|
||||
let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
|
||||
let proto_flags = ProtocolFlags::TCP | ProtocolFlags::UDP;
|
||||
let sockets_info = get_sockets_info(af_flags, proto_flags).unwrap();
|
||||
let moved_sockets = sockets_info.clone();
|
||||
for si in moved_sockets {
|
||||
match si.protocol_socket_info {
|
||||
ProtocolSocketInfo::Tcp(tcp_si) => println!(
|
||||
"TCP {}:{} -> {}:{} {:?} - {}",
|
||||
tcp_si.local_addr,
|
||||
tcp_si.local_port,
|
||||
tcp_si.remote_addr,
|
||||
tcp_si.remote_port,
|
||||
si.associated_pids,
|
||||
tcp_si.state
|
||||
),
|
||||
ProtocolSocketInfo::Udp(udp_si) => println!(
|
||||
"UDP {}:{} -> *:* {:?}",
|
||||
udp_si.local_addr, udp_si.local_port, si.associated_pids
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
NETMon {
|
||||
triggered: false,
|
||||
interfaces: vec![],
|
||||
settings_map,
|
||||
state: Arc::new(Mutex::new(sockets_info.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventMonitor for NETMon {
|
||||
async fn check(&mut self) {
|
||||
if let Ok(check) = httping::ping("koonts.net", "", "https", 443).await {
|
||||
self.triggered = !check
|
||||
}
|
||||
if self.triggered {
|
||||
println!("{} :: ALERT NET", Local::now());
|
||||
net_alert(self.settings_map.clone()).await;
|
||||
}
|
||||
// println!("check net: {}", self.triggered);
|
||||
}
|
||||
}
|
||||
|
||||
async fn net_alert(settings_map: Config) {
|
||||
reboot_system(settings_map).await;
|
||||
}
|
||||
87
src/monitors/notify/mod.rs
Normal file
87
src/monitors/notify/mod.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use std::env::consts::OS;
|
||||
use config::Config;
|
||||
use std::process::Command;
|
||||
use anyhow::anyhow;
|
||||
use discord_webhook_lib::DiscordMessage;
|
||||
|
||||
pub async fn send_discord(
|
||||
message: &str,
|
||||
settings_map: Config,
|
||||
append: &String,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
#[allow(unused)]
|
||||
let mut final_message = String::new();
|
||||
|
||||
final_message = match OS {
|
||||
"linux" => {
|
||||
if let Ok(output) = Command::new("hostnamectl").arg("hostname").output() {
|
||||
if let Ok(hostname) = String::from_utf8(output.stdout) {
|
||||
format!("{} Hostname: {} :: {}", message, hostname.trim(), append)
|
||||
} else {
|
||||
format!("{} Hostname: unknown :: {}", message, append)
|
||||
}
|
||||
} else {
|
||||
format!("{} Hostname: unknown :: {}", message, append)
|
||||
}
|
||||
},
|
||||
"macos" => {
|
||||
if let Ok(output) = Command::new("hostname").output() {
|
||||
if let Ok(hostname) = String::from_utf8(output.stdout) {
|
||||
format!("{} Hostname: {} :: {}", message, hostname.trim(), append)
|
||||
} else {
|
||||
format!("{} Hostname: unknown :: {}", message, append)
|
||||
}
|
||||
} else {
|
||||
format!("{} Hostname: unknown :: {}", message, append)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Ok(output) = Command::new("hostname").output() {
|
||||
if let Ok(hostname) = String::from_utf8(output.stdout) {
|
||||
format!("{} Hostname: {} :: {}", message, hostname.trim(), append)
|
||||
} else {
|
||||
format!("{} Hostname: unknown :: {}", message, append)
|
||||
}
|
||||
} else {
|
||||
format!("{} Hostname: unknown :: {}", message, append)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Default to no-op if webhook is not provided
|
||||
let discord_webhook_url = settings_map
|
||||
.get::<String>("discord_webhook_url")
|
||||
.unwrap_or_default();
|
||||
if discord_webhook_url.trim().is_empty() {
|
||||
return Ok(()); // Disabled by default if not configured
|
||||
}
|
||||
let discord_webhook_avatar_name = settings_map
|
||||
.get::<String>("discord_webhook_avatar_name")
|
||||
.unwrap_or_else(|_| "Watchman".to_string());
|
||||
let mut message = DiscordMessage::builder(discord_webhook_url);
|
||||
message.add_message(final_message);
|
||||
message.add_field("username", discord_webhook_avatar_name);
|
||||
let final_message = message.build();
|
||||
if final_message.send().await.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Failed to send discord message"))
|
||||
}
|
||||
}
|
||||
|
||||
// pub async fn send_email() {}
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// use discord_webhook_lib::send_discord;
|
||||
//
|
||||
// #[test]
|
||||
// fn test_send_wh() {
|
||||
// let rt = tokio::runtime::Runtime::new();
|
||||
// rt.unwrap().block_on(send_discord(
|
||||
// "",
|
||||
// "Hello World",
|
||||
// "Lazarus"
|
||||
// )).unwrap();
|
||||
// }
|
||||
// }
|
||||
84
src/monitors/ssh_burn_file.rs
Normal file
84
src/monitors/ssh_burn_file.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use crate::fortiwatch::EventMonitor;
|
||||
use crate::monitors::actions::unmount_encrypted_volumes;
|
||||
use chrono::{DateTime, Utc};
|
||||
use config::Config;
|
||||
use std::process::Command;
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug)]
|
||||
pub struct SSHBurnMon {
|
||||
triggered: bool,
|
||||
settings_map: Config,
|
||||
last_check: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl SSHBurnMon {
|
||||
pub fn new(settings_map: Config) -> Self {
|
||||
SSHBurnMon {
|
||||
triggered: false,
|
||||
settings_map,
|
||||
last_check: Utc::now(),
|
||||
}
|
||||
}
|
||||
async fn ssh_burn_triggered(&self) {
|
||||
if self
|
||||
.settings_map
|
||||
.get::<String>("unmount_crypt_on_file_burn")
|
||||
.unwrap()
|
||||
.eq_ignore_ascii_case("true")
|
||||
{
|
||||
unmount_encrypted_volumes().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventMonitor for SSHBurnMon {
|
||||
async fn check(&mut self) {
|
||||
let ssh_check_burn_check_interval = self
|
||||
.settings_map
|
||||
.get::<String>("ssh_check_burn_check_interval")
|
||||
.unwrap()
|
||||
.parse::<i64>()
|
||||
.unwrap();
|
||||
if Utc::now()
|
||||
.signed_duration_since(self.last_check)
|
||||
.num_seconds()
|
||||
> ssh_check_burn_check_interval
|
||||
{
|
||||
let ssh_check_burn_host = self
|
||||
.settings_map
|
||||
.get::<String>("ssh_check_burn_host")
|
||||
.unwrap();
|
||||
let ssh_check_burn_user = self
|
||||
.settings_map
|
||||
.get::<String>("ssh_check_burn_user")
|
||||
.unwrap();
|
||||
let ssh_check_burn_key = self
|
||||
.settings_map
|
||||
.get::<String>("ssh_check_burn_key")
|
||||
.unwrap();
|
||||
let ssh_check_burn_path = self
|
||||
.settings_map
|
||||
.get::<String>("ssh_check_burn_path")
|
||||
.unwrap();
|
||||
let addr = format!("{}@{}", ssh_check_burn_user, ssh_check_burn_host);
|
||||
let command_str = format!(
|
||||
"if [ -f {} ]; then cat {}; fi",
|
||||
ssh_check_burn_path, ssh_check_burn_path
|
||||
);
|
||||
if let Ok(result) = Command::new("ssh")
|
||||
.arg("-i")
|
||||
.arg(ssh_check_burn_key)
|
||||
.arg(addr)
|
||||
.arg(command_str)
|
||||
.output()
|
||||
{
|
||||
let burn_contents = String::from_utf8(result.stdout).unwrap();
|
||||
if burn_contents.eq_ignore_ascii_case("burn") {
|
||||
self.ssh_burn_triggered().await;
|
||||
}
|
||||
}
|
||||
self.last_check = Utc::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/prometheus/mod.rs
Normal file
45
src/prometheus/mod.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use actix_web::http::header;
|
||||
use actix_web::{get, web, App, Error, HttpResponse, HttpServer, Responder};
|
||||
use prometheus::{Encoder, TextEncoder};
|
||||
use std::thread;
|
||||
|
||||
pub struct Prometheus {}
|
||||
|
||||
impl Prometheus {
|
||||
pub fn new() -> Self {
|
||||
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(),
|
||||
);
|
||||
});
|
||||
// let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/metrics")]
|
||||
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();
|
||||
buffer.clear();
|
||||
|
||||
// timer.observe_duration();
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header(header::ContentType(mime::TEXT_PLAIN))
|
||||
.body(response))
|
||||
}
|
||||
224
src/util/mod.rs
Normal file
224
src/util/mod.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
use std::env::consts::OS;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn get_cache_dir() -> String {
|
||||
#[allow(unused)]
|
||||
let mut cache_dir = String::new();
|
||||
let cur_user = whoami::username();
|
||||
|
||||
match OS {
|
||||
"linux" => {
|
||||
if cur_user.eq_ignore_ascii_case("root") {
|
||||
let _ = fs::create_dir_all(Path::new("/root/.config/watchman/config/"));
|
||||
"/root/.config/watchman/".to_string()
|
||||
} else {
|
||||
let create_dir = format!("/home/{}/.config/watchman/config/", cur_user);
|
||||
let _ = fs::create_dir_all(Path::new(create_dir.as_str()));
|
||||
format!("/home/{}/.config/watchman/", cur_user)
|
||||
}
|
||||
}
|
||||
"macos" => {
|
||||
// /var/root/Library/Caches/com.helloimalemur.watchman/
|
||||
if cur_user.eq_ignore_ascii_case("root") {
|
||||
let _ = fs::create_dir_all(Path::new("/var/root/Library/Caches/com.helloimalemur.watchman/config/"));
|
||||
"/var/root/Library/Caches/com.helloimalemur.watchman/".to_string()
|
||||
} else {
|
||||
let create_dir = format!("/Users/{}/Library/Caches/com.helloimalemur.watchman/config/", cur_user);
|
||||
let _ = fs::create_dir_all(Path::new(create_dir.as_str()));
|
||||
format!("/Users/{}/Library/Caches/com.helloimalemur.watchman/", cur_user)
|
||||
}
|
||||
}
|
||||
"windows" => {
|
||||
// Prefer LOCALAPPDATA for cache
|
||||
if let Ok(local_appdata) = std::env::var("LOCALAPPDATA") {
|
||||
let base = format!("{}\\Watchman\\", local_appdata);
|
||||
let _ = fs::create_dir_all(Path::new(format!("{}config\\", base).as_str()));
|
||||
base
|
||||
} else {
|
||||
let base = format!("C:\\Users\\{}\\AppData\\Local\\Watchman\\", cur_user);
|
||||
let _ = fs::create_dir_all(Path::new(format!("{}config\\", base).as_str()));
|
||||
base
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Fallback to current directory for unknown OS
|
||||
let _ = fs::create_dir_all(Path::new("./config/"));
|
||||
"./".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn get_config_dir() -> String {
|
||||
#[allow(unused)]
|
||||
let mut cache_dir = String::new();
|
||||
let cur_user = whoami::username();
|
||||
|
||||
match OS {
|
||||
"linux" => {
|
||||
if cur_user.eq_ignore_ascii_case("root") {
|
||||
let _ = fs::create_dir_all(Path::new("/root/.config/watchman/config/"));
|
||||
"/root/.config/watchman/".to_string()
|
||||
} else {
|
||||
let create_dir = format!("/home/{}/.config/watchman/config/", cur_user);
|
||||
let _ = fs::create_dir_all(Path::new(create_dir.as_str()));
|
||||
format!("/home/{}/.config/watchman/", cur_user)
|
||||
}
|
||||
}
|
||||
"macos" => {
|
||||
// /var/root/Library/Application\ Support/com.helloimalemur.watchman/
|
||||
if cur_user.eq_ignore_ascii_case("root") {
|
||||
let _ = fs::create_dir_all(Path::new("/var/root/Library/Application\\ Support/com.helloimalemur.watchman/config/"));
|
||||
"/var/root/Library/Application\\ Support/com.helloimalemur.watchman/".to_string()
|
||||
} else {
|
||||
let create_dir = format!("/Users/{}/Library/Application\\ Support/com.helloimalemur.watchman/config/", cur_user);
|
||||
let _ = fs::create_dir_all(Path::new(create_dir.as_str()));
|
||||
format!("/Users/{}/Library/Application\\ Support/com.helloimalemur.watchman/", cur_user)
|
||||
}
|
||||
}
|
||||
"windows" => {
|
||||
// Prefer APPDATA (Roaming) for config
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
let base = format!("{}\\Watchman\\", appdata);
|
||||
let _ = fs::create_dir_all(Path::new(format!("{}config\\", base).as_str()));
|
||||
base
|
||||
} else {
|
||||
let base = format!("C:\\Users\\{}\\AppData\\Roaming\\Watchman\\", cur_user);
|
||||
let _ = fs::create_dir_all(Path::new(format!("{}config\\", base).as_str()));
|
||||
base
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Fallback to current directory for unknown OS
|
||||
let _ = fs::create_dir_all(Path::new("./config/"));
|
||||
"./".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_default_blacklist<T: ToString>(path: T) {
|
||||
let _ = fs::write(path.to_string(), default_blacklist());
|
||||
}
|
||||
|
||||
fn default_blacklist() -> &'static str {
|
||||
r#"
|
||||
/etc/mtab
|
||||
/etc/cups
|
||||
"#
|
||||
}
|
||||
|
||||
pub fn write_default_config<T: ToString>(path: T, webhook: String) {
|
||||
if fs::write(
|
||||
path.to_string(),
|
||||
default_config().replace("https://discord.com/api/webhooks/", webhook.as_str()),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
println!(
|
||||
"{}\n ~~~~~~~ UNABLE TO LOCATE CONFIG FILE - DEFAULT CONFIG CREATED ~~~~~~~",
|
||||
default_config().replace("https://discord.com/api/webhooks/", webhook.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_config() -> &'static str {
|
||||
match OS {
|
||||
"linux" => {
|
||||
r#"
|
||||
## General settings
|
||||
tick_delay_seconds = "5"
|
||||
fs_tick_delay_seconds = "300"
|
||||
|
||||
### File System Integrity
|
||||
fs_mon_path_variable = true
|
||||
fs_mon_enabled = "true"
|
||||
fs_mon_dir = ["/etc", "/bin", "$PATH"]
|
||||
fs_mon_hash_type = "blake3"
|
||||
|
||||
### USB Monitor
|
||||
usb_mon_enabled = "true"
|
||||
reboot_on_increase_of_usb_devices = "false"
|
||||
notify_on_increase_of_usb_devices = "false"
|
||||
unmount_crypt_on_increase_of_usb_devices = "true"
|
||||
|
||||
### Burn File Monitor
|
||||
burn_file_mon_enabled = "false"
|
||||
unmount_crypt_on_file_burn = "true"
|
||||
ssh_check_burn_host = "hostname"
|
||||
ssh_check_burn_user = "root"
|
||||
ssh_check_burn_key = "/home/user/.ssh/id_rsa"
|
||||
ssh_check_burn_path = "/root/.config/burn"
|
||||
ssh_check_burn_check_interval = "30"
|
||||
burn_path_1 = "/root/test/"
|
||||
|
||||
### Network Monitor
|
||||
net_mon_enabled = "false"
|
||||
|
||||
### Client Discovery & Scan
|
||||
client_scan_enabled = "false"
|
||||
client_discovery_method = "arp" # arp | ping
|
||||
client_scan_exclusions = ["127.0.0.1", "localhost"]
|
||||
scan_on_add = "true"
|
||||
nmap_path = "nmap"
|
||||
nmap_profile = "-sV -T4"
|
||||
findings_critical_ports = ["23", "2323", "3389", "5900"]
|
||||
notify_on_findings = "false"
|
||||
action_on_findings = "none" # none | reboot
|
||||
|
||||
######## Notification settings
|
||||
discord_webhook_url = "https://discord.com/api/webhooks/"
|
||||
discord_webhook_avatar_name = "Lazarus"
|
||||
"#
|
||||
}
|
||||
|
||||
"macos" => {
|
||||
r#"
|
||||
## General settings
|
||||
tick_delay_seconds = "5"
|
||||
fs_tick_delay_seconds = "300"
|
||||
|
||||
### File System Integrity
|
||||
fs_mon_path_variable = true
|
||||
fs_mon_enabled = "true"
|
||||
fs_mon_dir = ["/etc", "/bin", "$PATH"]
|
||||
fs_mon_hash_type = "blake3"
|
||||
|
||||
### USB Monitor
|
||||
usb_mon_enabled = "true"
|
||||
reboot_on_increase_of_usb_devices = "false"
|
||||
notify_on_increase_of_usb_devices = "true"
|
||||
unmount_crypt_on_increase_of_usb_devices = "true"
|
||||
|
||||
### Burn File Monitor
|
||||
burn_file_mon_enabled = "false"
|
||||
unmount_crypt_on_file_burn = "true"
|
||||
ssh_check_burn_host = "hostname"
|
||||
ssh_check_burn_user = "root"
|
||||
ssh_check_burn_key = "/User/user/.ssh/id_rsa"
|
||||
ssh_check_burn_path = "/var/root/.config/burn"
|
||||
ssh_check_burn_check_interval = "30"
|
||||
burn_path_1 = "/var/root/test/"
|
||||
|
||||
### Network Monitor
|
||||
net_mon_enabled = "false"
|
||||
|
||||
### Client Discovery & Scan
|
||||
client_scan_enabled = "false"
|
||||
client_discovery_method = "arp" # arp | ping
|
||||
client_scan_exclusions = ["127.0.0.1", "localhost"]
|
||||
scan_on_add = "true"
|
||||
nmap_path = "nmap"
|
||||
nmap_profile = "-sV -T4"
|
||||
findings_critical_ports = ["23", "2323", "3389", "5900"]
|
||||
notify_on_findings = "true"
|
||||
action_on_findings = "none" # none | reboot
|
||||
|
||||
######## Notification settings
|
||||
discord_webhook_url = "https://discord.com/api/webhooks/"
|
||||
discord_webhook_avatar_name = "Lazarus"
|
||||
"#
|
||||
}
|
||||
_ => {
|
||||
panic!("Unsupported OS: {}", OS);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user