From e3a5efe56fde535edd58a362e537363fb3671258 Mon Sep 17 00:00:00 2001 From: helloimalemur Date: Wed, 17 Sep 2025 12:13:02 -0400 Subject: [PATCH] first commit --- .gitignore | 4 + Cargo.toml | 31 ++++ README.md | 60 +++++++ install.sh | 3 + plan/features.md | 113 ++++++++++++ plan/monetization.md | 15 ++ plan/plan.md | 234 ++++++++++++++++++++++++ run.sh | 2 + src/arguments.rs | 21 +++ src/config/linux.rs | 59 ++++++ src/config/macos.rs | 85 +++++++++ src/config/mod.rs | 21 +++ src/fortiwatch.rs | 193 ++++++++++++++++++++ src/main.rs | 23 +++ src/monitors/actions/mod.rs | 82 +++++++++ src/monitors/clients.rs | 296 ++++++++++++++++++++++++++++++ src/monitors/devices.rs | 182 +++++++++++++++++++ src/monitors/filechanges.rs | 327 ++++++++++++++++++++++++++++++++++ src/monitors/mod.rs | 7 + src/monitors/network.rs | 67 +++++++ src/monitors/notify/mod.rs | 87 +++++++++ src/monitors/ssh_burn_file.rs | 84 +++++++++ src/prometheus/mod.rs | 45 +++++ src/util/mod.rs | 224 +++++++++++++++++++++++ watchman.service | 11 ++ 25 files changed, 2276 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100755 install.sh create mode 100644 plan/features.md create mode 100644 plan/monetization.md create mode 100644 plan/plan.md create mode 100755 run.sh create mode 100644 src/arguments.rs create mode 100644 src/config/linux.rs create mode 100644 src/config/macos.rs create mode 100644 src/config/mod.rs create mode 100644 src/fortiwatch.rs create mode 100644 src/main.rs create mode 100644 src/monitors/actions/mod.rs create mode 100644 src/monitors/clients.rs create mode 100644 src/monitors/devices.rs create mode 100644 src/monitors/filechanges.rs create mode 100644 src/monitors/mod.rs create mode 100644 src/monitors/network.rs create mode 100644 src/monitors/notify/mod.rs create mode 100644 src/monitors/ssh_burn_file.rs create mode 100644 src/prometheus/mod.rs create mode 100644 src/util/mod.rs create mode 100644 watchman.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ec22d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +config/Settings.toml +Cargo.lock +.idea/ +target/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..75be734 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "watchman" +version = "0.2.2" +edition = "2021" +description = "Detect and Act on unauthorized access of any kind from any source." +authors = ["jkoontsiii@gmail.com"] +license = "MIT" +repository = "https://github.com/helloimalemur/fortiwatch" +keywords = ["file-integrity", "filesystem-integirty", "change-detection", "watchman"] +readme = "README.md" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix = "0.13.5" +actix-web = "4.10.2" +anyhow = "1.0.97" +blake3 = "1.6.1" +chrono = "0.4.40" +clap = { version = "4.5.32", features = ["derive"] } +config = "0.15.11" +discord-webhook-lib = "0.2.1" +filesystem-hashing = "0.3.4" +httping = "0.1.9" +mime = "0.3.17" +netstat = "0.7.0" +prometheus = { version = "0.13.4" } +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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ee3533 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Watchman +#### (work-in-progress) +## Detect and Act on unauthorized access of any kind from any source + +### Detect and Act on; + - an increase of USB devices + - network issues or network failure + - filesystem changes + - ssh "burn file" + +#### Observed memory usage <100MB to ~1GB + +### Install +```shell +## install binary +cargo install watchman +## configure service with discord webhook +watchman install-service webhook=https://discordapp.com/api/webhooks/121946119953658680... +``` + +# Setup +### create config/Settings.toml +```shell +## General settings +tick_delay_seconds = "5" +fs_tick_delay_seconds = "60" + +### File System Integrity +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 = "/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" + +######## Notification settings +discord_webhook_url = "https://discord.com/api/webhooks/" +discord_webhook_avatar_name = "Lazarus" +``` + +## Development and Collaboration +#### Feel free to open a pull request, please run the following prior to your submission please! + echo "Run clippy"; cargo clippy -- -D clippy::all + echo "Format source code"; cargo fmt -- --check diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..07c36a1 --- /dev/null +++ b/install.sh @@ -0,0 +1,3 @@ +#!/bin/bash +sudo cargo install --path ./ --root /usr/local/ +sudo /usr/local/bin/watchman -s install \ No newline at end of file diff --git a/plan/features.md b/plan/features.md new file mode 100644 index 0000000..77d77ee --- /dev/null +++ b/plan/features.md @@ -0,0 +1,113 @@ +Here’s a breakdown of how you can implement the free and paid features in a structured way, keeping the architecture scalable. + +⸻ + +Feature Implementation Breakdown (Free vs. Paid) + +1. Core Monitoring (Free) + +✅ Implemented in Open-Source Version +• USB device monitoring +• Filesystem integrity checks +• Network failure detection +• “Burn file” SSH monitoring +• Discord webhook notifications + +Implementation: +• Filesystem Monitoring: Use inotify (Linux) or fswatch (cross-platform). +• USB Monitoring: Parse /sys/bus/usb/devices/ or lsusb output. +• Network Monitoring: Use ping or netlink API to detect failures. +• SSH Burn File: Periodically check for the existence of a file (stat(), fs::metadata() in Rust). + +⸻ + +2. USB & Peripheral Lockdown (Paid) + +🚀 Paid Feature +• Automatically disable unauthorized USB devices. +• Whitelist and blacklist management. + +Implementation: +• Detect devices using udevadm monitor --property. +• Maintain an allowlist of trusted USB device IDs. +• On detection of unauthorized devices: +• Linux: Run echo "1" > /sys/bus/usb/devices/usbX/remove +• Windows: Use PowerShell scripts to disable USB ports. + +⸻ + +3. Advanced Notifications & Webhooks (Paid) + +🚀 Paid Feature +• Support for Slack, Telegram, Microsoft Teams, Email, and SMS alerts. + +Implementation: +• Add support for multiple API integrations (e.g., Slack Webhooks, Twilio for SMS). +• Use an event-driven system to trigger notifications based on user-defined rules. + +⸻ + +4. Cloud Logging & Threat Intelligence (Paid) + +🚀 Paid Feature +• Store logs remotely for auditing. +• Use AI to detect unusual patterns. + +Implementation: +• Use Rust-based REST API (Actix/Web or Axum) to send logs to a cloud storage backend. +• Provide encrypted logs stored in a database (PostgreSQL, SQLite). +• Apply basic anomaly detection (e.g., tracking abnormal file deletions or SSH logins). + +⸻ + +5. Tamper Resistance & Self-Healing (Paid) + +🚀 Paid Feature +• Auto-recover from unauthorized file changes. +• Prevent attackers from disabling the system. + +Implementation: +• Detect unauthorized changes using hash comparison (Blake3). +• Auto-restore critical files from a backup archive if tampered. +• Use a hidden watchdog process that restarts monitoring services if terminated. + +⸻ + +6. Mobile Dashboard & Web UI (Paid) + +🚀 Paid Feature +• Control system settings from a web app or mobile device. + +Implementation: +• Build a Rust-based API backend with a database. +• Use React Native for a simple cross-platform mobile app. +• Connect API to fetch logs and allow actions (e.g., remote USB lockdown). + +⸻ + +7. Advanced Response Actions (Paid) + +🚀 Paid Feature +• Automatically lock the system or rotate SSH keys if a threat is detected. + +Implementation: +• Run predefined system commands (e.g., shutdown -h now). +• Auto-rotate SSH keys using ssh-keygen and update ~/.ssh/authorized_keys. + +⸻ + +Code Structure & Monetization Approach +1. Free Version +• Core monitoring features. +• Basic alerting via Discord. +• Local-only logs. +2. Paid Version +• Feature flagging system (cfg(feature = "paid") in Rust). +• API keys to unlock premium features (cloud logging, advanced alerts). +• Subscription-based licensing. + +⸻ + +Next Steps + +Would you like boilerplate Rust code for the paid features implementation? 🚀 \ No newline at end of file diff --git a/plan/monetization.md b/plan/monetization.md new file mode 100644 index 0000000..e657c76 --- /dev/null +++ b/plan/monetization.md @@ -0,0 +1,15 @@ +# SentinelGuard - Free vs. Paid Features + +| Feature | **Free Version** | **Paid Version** | +|---------|----------------|-----------------| +| **Basic Intrusion Detection** | ✅ Monitors USB, Filesystem, SSH, and Network | ✅ Advanced detection algorithms, anomaly tracking | +| **USB Monitoring** | ✅ Alerts on USB device changes | ✅ Auto-disable unauthorized USB devices, whitelist/blacklist management | +| **Filesystem Integrity Monitoring** | ✅ Detects changes in sensitive directories | ✅ Automated rollback, ransomware protection | +| **Network Monitoring** | ✅ Detects network failures | ✅ Logs suspicious traffic, integrates with SIEM tools | +| **"Burn File" SSH Monitoring** | ✅ Alerts when a specific file is removed | ✅ Auto-revoke SSH keys, force logout, disable user | +| **Notifications & Webhooks** | ✅ Discord webhook support | ✅ Slack, Telegram, Microsoft Teams, Email, and SMS alerts | +| **Remote Logging & Cloud Sync** | ❌ Local logs only | ✅ Secure cloud logging, real-time alerts | +| **Threat Intelligence & Machine Learning** | ❌ Simple rule-based detection | ✅ AI-driven anomaly detection, auto-response recommendations | +| **Tamper Resistance & Self-Healing** | ❌ Alerts on unauthorized changes | ✅ Auto-recovery from system tampering, self-healing configs | +| **Mobile Dashboard & Web UI** | ❌ CLI only | ✅ Web and mobile app for managing policies, alerts, and responses | +| **Advanced Response Actions** | ❌ Manual intervention needed | ✅ Automated lockdown, key rotation, and system reboots | \ No newline at end of file diff --git a/plan/plan.md b/plan/plan.md new file mode 100644 index 0000000..1a0ee64 --- /dev/null +++ b/plan/plan.md @@ -0,0 +1,234 @@ +# Watchman — Product Plan and Roadmap + +Last updated: 2025-09-17 10:54 local + +This document lays out where Watchman is today, the features we will add next, and a pragmatic, incremental path to get there. It complements the existing planning notes in: +- plan/features.md — initial Free vs. Paid feature thinking +- plan/monetization.md — high-level monetization comparison + +The focus here is on technical scope, sequence, and acceptance criteria. + + +## 1) Vision and Principles + +Watchman provides multi-signal host monitoring and automated response to help detect and mitigate unauthorized access or suspicious changes on a system. Key principles: +- Defense-in-depth: multiple independent signals with clear provenance. +- Actionable by default: every detection can notify and/or trigger a safe, reversible response. +- Secure by default: hardened runtime, least-privilege, safe config parsing. +- Observability: clear logs, metrics, and health checks. +- Cross-platform where feasible; optimize for Linux first. + + +## 2) Current State (v0.2.x) + +Already in the repository (per README and codebase): +- Monitors + - USB activity: detect increases in connected USB devices; optional unmount/reboot/notify. + - File system integrity: hash-based monitoring for specified directories (e.g., /etc, /bin, $PATH) using blake3. + - Network: detect network issues/failures. + - SSH “burn file”: remote path check that can trigger protective actions. +- Notification: Discord webhook alerts. +- Config: Settings.toml with intervals, toggles, directories, webhook URL. +- Runtime: Rust async, uses tokio/actix; prometheus crate is present. + +Gaps +- Metrics exist in codebase structure but not fully surfaced/standardized. +- Limited notification channels (Discord only). +- Validation of config values and schema evolution not formalized. +- Tests are limited. No fuzzing or integration test harness. +- Cross-platform support unclear (Linux focused). +- Service management, packaging, and upgrade story can be improved. + + +## 3) Near-Term Goals (security and reliability first) + +- Hardening: minimize privileges, sanitize command execution, robust error handling. +- Deterministic behavior: clear state machine for each monitor, idempotent actions. +- Observability: consistent metrics and structured logs for every monitor and action. +- Safer configuration: validation, defaults, and deprecation path. + + +## 4) Feature Roadmap (Phased) + +The roadmap uses small, shippable milestones. Each item lists key deliverables and acceptance criteria. + +### Milestone A — 0.3.0: Observability & Config Hygiene +- Prometheus metrics v1 + - Counters for events by monitor (usb_events_total, fs_events_total, net_failures_total, burn_events_total) + - Gauges for current connected USB count, last_success_timestamp per monitor + - Histogram for monitor latency and action execution duration + - /metrics endpoint gated by bind address (default: localhost) and optional auth token + - Acceptance: metrics documented in README; basic dashboard example provided. +- Structured logging + - Add consistent, machine-parseable logs (e.g., JSON via env-controlled format) + - Include correlation IDs per tick/incident + - Acceptance: toggle via config; example jq commands in README +- Config validation + - Strongly-typed Settings with explicit defaults + - Startup validation with actionable error messages + - Acceptance: invalid config causes non-zero exit with guidance; sample Settings.toml updated + +### Milestone B — 0.3.1: Notification Channels v2 +- Add Slack and generic webhook support +- Pluggable notifier trait; Discord refactored to use it +- Basic rate limiting and de-dup window to avoid alert storms +- Acceptance: e2e tests that simulate events and assert notifier behavior + +### Milestone C — 0.3.2: Filesystem Integrity v2 +- Baseline management (init, refresh, compare) +- Exclude/include patterns; per-path policies +- Optional immutable baseline storage (append-only file) and checksum manifest +- Acceptance: CLI subcommands watchman fs init|refresh|diff and docs + +### Milestone D — 0.3.3: USB Monitor v2 +- Trusted device allowlist (vendor:product IDs) and policy actions (alert-only, unmount, power-cycle where supported) +- Cooldown and backoff to avoid flapping +- Acceptance: simulator/test harness to inject fake USB events + +### Milestone E — 0.3.4: Network Monitor v2 +- Multiple targets, success quorum, and jittered intervals +- Optional traceroute-on-failure (best effort) +- Acceptance: integration tests with a local dummy target + +### Milestone F — 0.4.0: Response Actions Framework +- Action registry (unmount, reboot, kill-process, rotate-keys [stub], run-script) +- Pre- and post-conditions; dry-run mode; per-action timeouts +- Acceptance: action plans expressed in config; unit tests for each action + +### Milestone G — 0.4.1: Health and Control API +- HTTP control plane (bind-local by default): + - GET /healthz, /readyz, /metrics + - POST /actions/{name} with dry-run and audit +- Acceptance: integration tests; example curl commands in README + +### Milestone H — 0.4.2: Packaging & Service +- Systemd unit hardening (CapabilityBoundingSet, NoNewPrivileges, ProtectSystem, etc.) +- Deb/RPM packaging; Homebrew Tap formula for macOS CLI-only +- Acceptance: reproducible binaries, install docs, and service hardening checklist + +### Milestone I — 0.5.x: Cross-Platform & Extensibility +- macOS: fs events via FSEvents; limited USB visibility; launchd plist +- Windows: filesystem via USN Journal (scoped); service integration; USB capabilities evaluated +- Plugin interface (WASM or dynamic dispatch) for third-party monitors +- Acceptance: CI builds for Linux/macOS; Windows nightly artifacts + +Note: If monetization becomes relevant later, see plan/features.md and plan/monetization.md for an initial split. We will keep the open-source core useful and secure. + + +## 5) Architecture Notes + +- Monitors + - Each monitor is an independent component with a common trait: poll(), emit events, and register metrics. + - Monitors should be stateless across ticks where possible; persisted state is explicit (e.g., fs baseline). +- Event Bus + - Internally, use a channel-based event bus to decouple detection from actions/notifications. + - Events carry severity, source, timestamp, and correlation ID. +- Actions + - Declarative action specifications (conditions + operations). Support dry-run. +- Notifiers + - Implement a notifier trait with backoff, jitter, and rate-limiting wrappers. +- Configuration + - Strongly typed, documented, validated; feature flags guarded with clear defaults. + + +## 6) Configuration Evolution (draft) + +Add or refine keys in config/Settings.toml: +- [general] + - tick_delay_seconds, fs_tick_delay_seconds + - log_format = "text"|"json" + - metrics_bind_addr = "127.0.0.1:9898" + - metrics_auth_token = "" # optional +- [notifications] + - discord_webhook_url = "..." (existing) + - slack_webhook_url = "..." + - generic_webhook_url = "..." + - notify_dedupe_window_secs = 30 +- [fs] + - enabled = true + - dirs = ["/etc", "/bin", "$PATH"] + - exclude = ["/etc/ssl/**"] + - baseline_path = "/var/lib/watchman/fs-baseline.json" + - hash = "blake3" +- [usb] + - enabled = true + - allowlist = ["abcd:1234", "1d6b:0002"] + - action_on_unauthorized = "alert|unmount|reboot|none" + - cooldown_secs = 10 +- [net] + - enabled = false + - targets = ["1.1.1.1", "8.8.8.8"] + - quorum = 1 + - interval_jitter_pct = 20 +- [burn] + - enabled = false + - ssh_host = "hostname" + - ssh_user = "root" + - ssh_key = "/home/user/.ssh/id_rsa" + - path = "/root/.config/burn" + - check_interval_secs = 30 + + +## 7) Security & Hardening Checklist + +- Run as dedicated user; least filesystem permissions. +- Systemd hardening options enabled by default. +- Validate and sanitize all external inputs (paths, webhook URLs, command args). +- Cryptographic hashing via blake3; check for algorithm agility hooks. +- Protect secrets in memory where possible; avoid logging sensitive values. +- Optional signed baseline and config (future). + + +## 8) Observability & Diagnostics + +- Prometheus metrics as in Milestone A. +- Structured logs with correlation IDs. +- Debug bundle command: watchman diag bundle → collects versions, last logs, metrics snapshot (no secrets). + + +## 9) CLI and UX + +- watchman run — start monitors with current config. +- watchman fs init|refresh|diff — manage filesystem baseline. +- watchman validate — validate config and report errors. +- watchman install-service webhook= — keep for quick start; document flags. + + +## 10) Testing Strategy + +- Unit tests per monitor and notifier. +- Integration tests spawning the binary with temp configs. +- Property-based tests for config parsing/validation. +- Fuzz critical parsers (USB sysfs parsing, config loader). +- Golden tests for logs/metrics outputs. +- CI: run clippy, fmt, tests; produce artifacts for Linux (and later macOS/Windows). + + +## 11) Performance Targets + +- Idle memory: < 150 MB typical on Linux with all monitors enabled. +- CPU: < 2% average on a modern 2-core VM at default intervals. +- Baseline diff for 100k files: < 30s on SSD; incremental checks amortized. + + +## 12) Risks and Mitigations + +- False positives driving destructive actions → default to alert-only; require explicit opt-in for destructive actions; dry-run support. +- Platform differences (USB, FS APIs) → feature gating; per-OS implementations. +- Metric/telemetry exposure → bind-local by default; optional token; document firewalling. + + +## 13) Release Cadence and Versioning + +- Minor iterations every 2–4 weeks; patch releases as needed. +- Semantic versioning: 0.x minor bumps may include breaking changes, documented in CHANGELOG. + + +## 14) Immediate Next Steps (for maintainers) + +1. Define metric names and add minimal /metrics endpoint (A). +2. Add config validation with friendly errors (A). +3. Introduce notifier trait and migrate Discord (B). +4. Draft fs baseline CLI skeleton (C). + +Track progress via issues and link them back to this plan. diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..511605c --- /dev/null +++ b/run.sh @@ -0,0 +1,2 @@ +#!/bin/bash +cd /var/lib/watchman && ./watchman diff --git a/src/arguments.rs b/src/arguments.rs new file mode 100644 index 0000000..3f1b4e5 --- /dev/null +++ b/src/arguments.rs @@ -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, + #[arg(short, long)] + pub config_path: Option, + #[arg(short, long)] + pub webhook_url: Option, + #[arg(short, long)] + pub service: Option, + +} diff --git a/src/config/linux.rs b/src/config/linux.rs new file mode 100644 index 0000000..e7b635c --- /dev/null +++ b/src/config/linux.rs @@ -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(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 + "# +} \ No newline at end of file diff --git a/src/config/macos.rs b/src/config/macos.rs new file mode 100644 index 0000000..cf47864 --- /dev/null +++ b/src/config/macos.rs @@ -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(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#" + + + + + Label + com.helloimalemur.watchman + + ProgramArguments + + /usr/local/bin/watchman + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /var/log/watchman.log + + StandardErrorPath + /var/log/watchman_errors.log + + + "# +} \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..ffce722 --- /dev/null +++ b/src/config/mod.rs @@ -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); + } + } +} \ No newline at end of file diff --git a/src/fortiwatch.rs b/src/fortiwatch.rs new file mode 100644 index 0000000..8fe5048 --- /dev/null +++ b/src/fortiwatch.rs @@ -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>>, + 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 = vec![]; + if settings + .get::("usb_mon_enabled") + .unwrap() + .eq_ignore_ascii_case("true") + { + monitors.push(Monitors::USBMon(USBMon::new(settings.clone()))); + } + // if settings + // .get::("net_mon_enabled") + // .unwrap() + // .eq_ignore_ascii_case("true") + // { + // monitors.push(Monitors::NetMon(NETMon::new(settings.clone()))); + // } + if settings + .get::("burn_file_mon_enabled") + .unwrap() + .eq_ignore_ascii_case("true") + { + monitors.push(Monitors::SSHBurnMon(SSHBurnMon::new(settings.clone()))); + } + if settings + .get::("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::("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::("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 + .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"); + 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"); + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6875b64 --- /dev/null +++ b/src/main.rs @@ -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; +} diff --git a/src/monitors/actions/mod.rs b/src/monitors/actions/mod.rs new file mode 100644 index 0000000..cbf4bfd --- /dev/null +++ b/src/monitors/actions/mod.rs @@ -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()); +// } +// } diff --git a/src/monitors/clients.rs b/src/monitors/clients.rs new file mode 100644 index 0000000..a09fd47 --- /dev/null +++ b/src/monitors/clients.rs @@ -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, + last_seen: HashMap, + 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::("client_discovery_method") + .unwrap_or("arp".to_string()); + let exclusions: Vec = self + .settings_map + .get::>("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::("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 { + let mut ips: Vec = 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 { + // 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::("nmap_path") + .unwrap_or("nmap".to_string()); + let nmap_profile = settings_map + .get::("nmap_profile") + .unwrap_or("-sV -T4".to_string()); + + // Build command: nmap [profile] -oX - 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 = settings_map + .get::>("findings_critical_ports") + .unwrap_or(vec!["23".to_string(), "2323".to_string(), "3389".to_string(), "5900".to_string()]); + + let mut critical_hits: Vec = vec![]; + for p in &open_ports { + if critical_ports.iter().any(|c| c == p) { + critical_hits.push(p.clone()); + } + } + + let notify = settings_map + .get::("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::("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 { + // Very naive XML parsing: look for ... + let mut ports: Vec = vec![]; + let bytes = xml.as_bytes(); + let mut i = 0; + while let Some(port_idx) = find_sub(bytes, i, b"") { + 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 { + hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +fn find_quote(hay: &[u8], from: usize) -> Option { + for i in from..hay.len() { + if hay[i] == b'"' { return Some(i); } + } + None +} diff --git a/src/monitors/devices.rs b/src/monitors/devices.rs new file mode 100644 index 0000000..3d44396 --- /dev/null +++ b/src/monitors/devices.rs @@ -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, + 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 = 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 { + let mut devices: Vec = 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 { + let mut devices: Vec = 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::("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::("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; + } +} diff --git a/src/monitors/filechanges.rs b/src/monitors/filechanges.rs new file mode 100644 index 0000000..70347dc --- /dev/null +++ b/src/monitors/filechanges.rs @@ -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, + snapshots: Vec, + hash_type: HashType, + settings_map: Config, + black_list: Vec, + 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::("fs_mon_path_variable") + { + if b { + let path = env::var("PATH").unwrap_or_else(|_| String::new()); + let split = path.split(':').collect::>(); + 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 = 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::>(); + // 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 { + let mut dirs: Vec = vec![]; + let mon_dirs = settings_map.get::>("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::>(); + 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::("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 { + let mut black_list: Vec = 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, + pub deleted: Vec, + pub changed: Vec, +} + +async fn compare_all_snapshots( + file_changes: &mut FileChanges, + _settings_map: Config, + black_list: Vec, +) -> Option<(SnapshotChangeType, SnapshotCompareResult)> { + let mut created: Vec = vec![]; + let mut deleted: Vec = vec![]; + let mut changed: Vec = vec![]; + let mut to_remove: Vec = vec![]; + let mut new_sn: Vec = 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); + } +} diff --git a/src/monitors/mod.rs b/src/monitors/mod.rs new file mode 100644 index 0000000..cbbb661 --- /dev/null +++ b/src/monitors/mod.rs @@ -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; diff --git a/src/monitors/network.rs b/src/monitors/network.rs new file mode 100644 index 0000000..df92083 --- /dev/null +++ b/src/monitors/network.rs @@ -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, + settings_map: Config, + state: Arc>> +} + +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; +} diff --git a/src/monitors/notify/mod.rs b/src/monitors/notify/mod.rs new file mode 100644 index 0000000..072e689 --- /dev/null +++ b/src/monitors/notify/mod.rs @@ -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::("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::("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(); +// } +// } diff --git a/src/monitors/ssh_burn_file.rs b/src/monitors/ssh_burn_file.rs new file mode 100644 index 0000000..b225980 --- /dev/null +++ b/src/monitors/ssh_burn_file.rs @@ -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, +} + +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::("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::("ssh_check_burn_check_interval") + .unwrap() + .parse::() + .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::("ssh_check_burn_host") + .unwrap(); + let ssh_check_burn_user = self + .settings_map + .get::("ssh_check_burn_user") + .unwrap(); + let ssh_check_burn_key = self + .settings_map + .get::("ssh_check_burn_key") + .unwrap(); + let ssh_check_burn_path = self + .settings_map + .get::("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(); + } + } +} diff --git a/src/prometheus/mod.rs b/src/prometheus/mod.rs new file mode 100644 index 0000000..99ae2b4 --- /dev/null +++ b/src/prometheus/mod.rs @@ -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 { + 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)) +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..1ea2cfc --- /dev/null +++ b/src/util/mod.rs @@ -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(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(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); + } + } +} \ No newline at end of file diff --git a/watchman.service b/watchman.service new file mode 100644 index 0000000..ea963bf --- /dev/null +++ b/watchman.service @@ -0,0 +1,11 @@ +[Unit] +Description=Watchman + +[Service] +Type=simple +User=root +Group=root +ExecStart=watchman + +[Install] +WantedBy=multi-user.target