diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2dffdbe..d00be38 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1540,6 +1540,7 @@ dependencies = [ "include_dir", "lazy_static", "log", + "once_cell", "pnet", "pushover", "r2d2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 47648aa..25fb8d4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -23,3 +23,4 @@ axum = "0.8.8" tower-http = { version = "0.6.8", features = ["fs"] } r2d2 = "0.8.10" r2d2_sqlite = "0.32.0" +once_cell = "1.21.3" diff --git a/backend/oott2.toml b/backend/oott2.toml new file mode 100644 index 0000000..d3f1a77 --- /dev/null +++ b/backend/oott2.toml @@ -0,0 +1,22 @@ +[database] +path="./oott.db" + +[networking] +interface = "wlp3s0" # Network interface to use for scans + +[log] +level = "debug" # off, error, warn, info, debug, trace + +[timings] +wait_between_scans="30m" # Wait time between scans. This does not include the scan time +arp_sender_timeout="1m" # If the ARP sender process takes longer than this it will be stopped (for a class C network - 254 IPs - it should take less than a minute) +arp_scan_duration="2m" # How long to wait for response packets on each scan (5m to 10m is a good timeframe for a class B or C network) + +[notifications] +# method="pushover" # For now just pushover, you can set this to "none" to avoid sending notifications (it will just log) +method="none" +notify_when_not_seen_for="1w" # Send a notification if a device comes back online after not being seen for this timeframe + +[notifications.pushover] +token="a7vxi8b24ncdmuyt65x7zytkxaozi3" # Your pushover token goes here, just copy&paste from their website after creating the app +user_key="u872udy8xc8ceys9ee6iur229xs3fj" # User key goes here, this is the account wide code for pushover diff --git a/backend/src/db.rs b/backend/src/db.rs index 96a5931..4cf1f37 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -9,7 +9,7 @@ use r2d2_sqlite::SqliteConnectionManager; use rusqlite_migration::Migrations; use tokio::sync::Mutex; -use crate::settings; +use crate::settings::get_settings; static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/database_migrations"); static INITIALISED: Mutex = Mutex::const_new(false); @@ -20,7 +20,11 @@ lazy_static! { Migrations::from_directory(&MIGRATIONS_DIR).unwrap(); // TODO : Move pool size to configuration file - static ref POOL: r2d2::Pool = r2d2::Pool::builder().max_size(10).build(r2d2_sqlite::SqliteConnectionManager::file(settings::CONFIG.database.path.as_str())).unwrap(); + static ref POOL: r2d2::Pool = r2d2::Pool::builder(). + max_size(10). + build( + r2d2_sqlite::SqliteConnectionManager::file(get_settings().database.path.as_str()) + ).unwrap(); } pub fn get_db_connection() -> PooledConnection { diff --git a/backend/src/db/notifications.rs b/backend/src/db/notifications.rs index e8003b1..443e14e 100644 --- a/backend/src/db/notifications.rs +++ b/backend/src/db/notifications.rs @@ -1,8 +1,10 @@ use chrono::Local; +use log::debug; use crate::model::{self, notifications::Notification}; pub fn list() -> Vec { + debug!("Listing notifications"); let mut result = Vec::new(); result.push(Notification { id: 1, @@ -23,7 +25,7 @@ mod tests { #[tokio::test] async fn list_default() { - tests_common::setup_database().await; + tests_common::setup().await; let notifications = list(); assert_eq!(notifications.len(), 1); } diff --git a/backend/src/device_finders/arp.rs b/backend/src/device_finders/arp.rs index 7e05188..3e4f41b 100644 --- a/backend/src/device_finders/arp.rs +++ b/backend/src/device_finders/arp.rs @@ -1,7 +1,7 @@ mod packet_send_receive; use crate::model::devices::Device; -use crate::settings::CONFIG; +use crate::settings::get_settings; use duration_string::DurationString; use log::{debug, error, info, warn}; use packet_send_receive::{listen_for_packets, send_packet}; @@ -83,14 +83,17 @@ pub async fn find(interface: String) -> Result, String> { let send_interface = network_interface.clone(); // Get timeouts - let sender_timeout: Duration = CONFIG.timings.arp_sender_timeout.into(); + let sender_timeout: Duration = get_settings().timings.arp_sender_timeout.into(); info!( "Sender timeout set to {}", - CONFIG.timings.arp_sender_timeout + get_settings().timings.arp_sender_timeout ); - let scan_duration: Duration = CONFIG.timings.arp_scan_duration.into(); + let scan_duration: Duration = get_settings().timings.arp_scan_duration.into(); let receiver_timeout: Duration = scan_duration * 2; - info!("Scan duration set to {}", CONFIG.timings.arp_scan_duration); + info!( + "Scan duration set to {}", + get_settings().timings.arp_scan_duration + ); info!( "Receiver timeout set to {}", String::from(DurationString::from(receiver_timeout)) diff --git a/backend/src/events.rs b/backend/src/events.rs index 4f06754..37011e5 100644 --- a/backend/src/events.rs +++ b/backend/src/events.rs @@ -3,7 +3,7 @@ mod pushover; use std::time::Duration; use crate::model::devices::Device; -use crate::settings::CONFIG; +use crate::settings::get_settings; use chrono::Local; use duration_string::DurationString; use log::{debug, info, warn}; @@ -12,7 +12,7 @@ use log::{debug, info, warn}; fn send_message(body: String) -> Result<(), String> { debug!("About to send notification ({body})."); - match CONFIG.notifications.method.as_str() { + match get_settings().notifications.method.as_str() { "pushover" => { pushover::send_message(body)?; } @@ -36,7 +36,9 @@ pub fn trigger_existing_device(existing_device: Device, new_device: Device) -> R .to_std() .unwrap_or(Duration::from_secs(0)); - if elapsed_since_last_seen >= Duration::from(CONFIG.notifications.notify_when_not_seen_for) { + if elapsed_since_last_seen + >= Duration::from(get_settings().notifications.notify_when_not_seen_for) + { send_message(format!( "Device MAC {} - IP {} - Vendor {} came back online after {}.", new_device.mac_address, diff --git a/backend/src/events/pushover.rs b/backend/src/events/pushover.rs index 3562ad6..4bd3935 100644 --- a/backend/src/events/pushover.rs +++ b/backend/src/events/pushover.rs @@ -2,15 +2,15 @@ use log::{debug, error}; use pushover::API; use pushover::requests::message::SendMessage; -use crate::settings::CONFIG; +use crate::settings::get_settings; pub fn send_message(body: String) -> Result<(), String> { debug!("About to send message via pushover ({body})"); let api = API::new(); let msg = SendMessage::new( - CONFIG.notifications.pushover.token.as_str(), - CONFIG.notifications.pushover.user_key.as_str(), + get_settings().notifications.pushover.token.as_str(), + get_settings().notifications.pushover.user_key.as_str(), body, ); diff --git a/backend/src/main.rs b/backend/src/main.rs index 248963c..36e0a10 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,4 +1,5 @@ -use crate::settings::CONFIG; +use crate::settings::get_settings; +use clap::Parser; use log::{LevelFilter, info}; mod db; @@ -13,10 +14,27 @@ mod web_server; #[cfg(test)] mod tests_common; +// Command line parameters +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// Config file path + #[arg(short, long)] + config: Option, +} + #[tokio::main] async fn main() -> Result<(), String> { + // Parse command line parameters and init settings + // this is not thread safe so it needs to run just once + let args = Args::parse(); + let config_path = args + .config + .unwrap_or(settings::DEFAULT_CONFIG_FILE_PATH.to_string()); + settings::init(config_path); + // Initialize logging - let log_level = match CONFIG.log.level.as_str() { + let log_level = match get_settings().log.level.as_str() { "off" => LevelFilter::Off, "error" => LevelFilter::Error, "warn" => LevelFilter::Warn, diff --git a/backend/src/scanner.rs b/backend/src/scanner.rs index 4c7f775..78f922d 100644 --- a/backend/src/scanner.rs +++ b/backend/src/scanner.rs @@ -1,14 +1,15 @@ use crate::db; use crate::device_finders; use crate::events; -use crate::settings::CONFIG; +use crate::settings::get_settings; use log::{debug, info}; use tokio::time::{Duration, sleep}; pub async fn scan() -> Result<(), String> { loop { // Find online devices via ARP - let devices = device_finders::arp::find(CONFIG.networking.interface.to_string()).await?; + let devices = + device_finders::arp::find(get_settings().networking.interface.to_string()).await?; info!("Done with ARP probes"); info!("Found {} online devices", devices.iter().count()); @@ -44,8 +45,8 @@ pub async fn scan() -> Result<(), String> { } info!( "Scan finished. Sleeping for {} seconds", - CONFIG.timings.wait_between_scans + get_settings().timings.wait_between_scans ); - sleep(Duration::from(CONFIG.timings.wait_between_scans)).await; + sleep(Duration::from(get_settings().timings.wait_between_scans)).await; } } diff --git a/backend/src/settings.rs b/backend/src/settings.rs index f81313d..84a8336 100644 --- a/backend/src/settings.rs +++ b/backend/src/settings.rs @@ -1,8 +1,6 @@ -use clap::Parser; use config::{Config, ConfigError, File}; use duration_string::DurationString; -use lazy_static::lazy_static; -use log::{debug, error, info}; +use once_cell::sync::OnceCell; use serde::Deserialize; // ----------------------------------------------------------- @@ -54,29 +52,9 @@ pub struct Settings { // End configuration structure // ----------------------------------------------------------- -// Command line parameters relevant to configuration -#[derive(Parser, Debug)] -#[command(version, about, long_about = None)] -struct Args { - /// Config file path - #[arg(short, long)] - config: Option, -} - -const DEFAULT_CONFIG_FILE_PATH: &str = "./oott.toml"; - impl Settings { - pub fn new() -> Result { - debug!("Starting configuration"); - - let args_result = Args::try_parse(); - - let config_path = match args_result { - Ok(value) => value.config.unwrap_or(DEFAULT_CONFIG_FILE_PATH.to_string()), - Err(_) => DEFAULT_CONFIG_FILE_PATH.to_string(), - }; - - info!("Reading configuration from {}", config_path); + pub fn new(config_path: String) -> Result { + println!("Reading configuration from {}", config_path); let local_settings = Config::builder() .add_source(File::with_name(config_path.as_str())) @@ -86,12 +64,24 @@ impl Settings { } } -lazy_static! { - pub static ref CONFIG: Settings = match Settings::new() { - Ok(value) => value, - Err(error) => { - error!("Error reading configuration file: {error}"); - panic!("Error reading configuration file: {error}"); +pub const DEFAULT_CONFIG_FILE_PATH: &str = "./oott.toml"; +static SETTINGS: OnceCell = OnceCell::new(); + +pub fn get_settings() -> &'static Settings { + match SETTINGS.get() { + Some(value) => value, + None => { + println!( + "Configuration was not initialized, reading from default path ({})", + DEFAULT_CONFIG_FILE_PATH + ); + let settings = Settings::new(DEFAULT_CONFIG_FILE_PATH.to_string()).unwrap(); + let _ = SETTINGS.set(settings); + SETTINGS.get().unwrap() } - }; + } +} + +pub fn init(config_path: String) { + let _ = SETTINGS.set(Settings::new(config_path).unwrap()); } diff --git a/backend/src/tests_common.rs b/backend/src/tests_common.rs index 3e84607..0d64054 100644 --- a/backend/src/tests_common.rs +++ b/backend/src/tests_common.rs @@ -1,30 +1,59 @@ use crate::db; +use crate::settings::get_settings; use include_dir::{Dir, include_dir}; -use log::{debug, info}; -use rusqlite_migration::Migrations; -use std::sync::LazyLock; +use log::{LevelFilter, debug, info}; use tokio::sync::Mutex; -static TEST_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/tests_database_migrations"); - -// Define migrations. These are applied atomically. -static TEST_MIGRATIONS: LazyLock> = - LazyLock::new(|| Migrations::from_directory(&TEST_MIGRATIONS_DIR).unwrap()); +static TEST_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/tests/database_setup"); +// Use a Mutex bool as semaphore to ensure single initialization static INITIALISED: Mutex = Mutex::const_new(false); -pub async fn setup_database() { - debug!("About to setup database for testing."); +pub async fn setup() { + debug!("About to setup testing."); let mut initialised = INITIALISED.lock().await; if *initialised { - debug!("Database was already initialised, nothing to do."); + debug!("Testing was already initialised, nothing to do."); return; } + + // Initialize logging + let log_level = match get_settings().log.level.as_str() { + "off" => LevelFilter::Off, + "error" => LevelFilter::Error, + "warn" => LevelFilter::Warn, + "info" => LevelFilter::Info, + "debug" => LevelFilter::Debug, + "trace" => LevelFilter::Trace, + _ => LevelFilter::Error, + }; + + env_logger::Builder::new() + .filter(None, log_level) + .write_style(env_logger::WriteStyle::Always) + .init(); + + // Initialize database db::init_db().await.unwrap(); info!("Initializing database for testing."); - let mut conn = db::get_db_connection(); - debug!("Running migrations for testing."); - TEST_MIGRATIONS.to_latest(&mut conn).unwrap(); - debug!("Testing migrations run on database."); + let conn = db::get_db_connection(); + debug!("Running database setup scripts for testing."); + + for entry in TEST_MIGRATIONS_DIR.entries() { + if entry.path().extension().map_or(false, |ext| ext == "sql") { + debug!("About to run {}", entry.path().display()); + + if entry.path().extension().map_or(false, |ext| ext == "sql") { + let sql = entry.as_file().unwrap().contents_utf8().unwrap(); + conn.execute(&sql, []).unwrap_or_else(|err| { + panic!("Error executing script: {}", err); + }); + }; + } + } + + debug!("Database setup for testing."); + + // Done *initialised = true; } diff --git a/backend/tests_database_migrations/01-notifications/up.sql b/backend/tests/database_setup/01-notifications.sql similarity index 100% rename from backend/tests_database_migrations/01-notifications/up.sql rename to backend/tests/database_setup/01-notifications.sql