From 020c7d2c497404069a30df2b9cb2da0bac4d05ce Mon Sep 17 00:00:00 2001 From: rzuasti Date: Thu, 28 May 2026 17:57:26 -0400 Subject: [PATCH] Add ARP scanner status API endpoint and rename scanner module Renames scanner.rs to arp_scanner.rs to future-proof for additional scanner types. Renames the [timings] config section to [arp_scanner] and simplifies field names (arp_sender_timeout -> sender_timeout, arp_scan_duration -> scan_duration). Introduces arp_scanner_status module to track whether the scan is actively running or sleeping, and exposes this via a new GET /api/arp_scanner/status endpoint returning is_running, running_for_seconds, and next_run_in_seconds. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/{scanner.rs => arp_scanner.rs} | 12 ++- backend/src/arp_scanner_status.rs | 103 +++++++++++++++++++++ backend/src/device_finders/arp.rs | 8 +- backend/src/main.rs | 8 +- backend/src/settings.rs | 8 +- backend/src/web_server.rs | 6 ++ backend/src/web_server/arp_scanner.rs | 57 ++++++++++++ examples/sample_oott.toml | 6 +- 8 files changed, 193 insertions(+), 15 deletions(-) rename backend/src/{scanner.rs => arp_scanner.rs} (82%) create mode 100644 backend/src/arp_scanner_status.rs create mode 100644 backend/src/web_server/arp_scanner.rs diff --git a/backend/src/scanner.rs b/backend/src/arp_scanner.rs similarity index 82% rename from backend/src/scanner.rs rename to backend/src/arp_scanner.rs index 6ce190f..293bdfd 100644 --- a/backend/src/scanner.rs +++ b/backend/src/arp_scanner.rs @@ -1,12 +1,16 @@ +use crate::arp_scanner_status; use crate::db; use crate::device_finders; use crate::events; use crate::settings::get_settings; +use chrono::Utc; use log::{debug, info}; use tokio::time::{Duration, sleep}; pub async fn scan() -> Result<(), Box> { loop { + arp_scanner_status::set_running(); + // Find online devices via ARP let devices = device_finders::arp::find(get_settings().networking.interface.to_string()).await?; @@ -43,10 +47,14 @@ pub async fn scan() -> Result<(), Box> { } }; } + + let wait = Duration::from(get_settings().arp_scanner.wait_between_scans); + let next_scan_at = Utc::now() + chrono::Duration::from_std(wait).unwrap(); info!( "Scan finished. Sleeping for {}", - get_settings().timings.wait_between_scans + get_settings().arp_scanner.wait_between_scans ); - sleep(Duration::from(get_settings().timings.wait_between_scans)).await; + arp_scanner_status::set_waiting(next_scan_at); + sleep(wait).await; } } diff --git a/backend/src/arp_scanner_status.rs b/backend/src/arp_scanner_status.rs new file mode 100644 index 0000000..33fae39 --- /dev/null +++ b/backend/src/arp_scanner_status.rs @@ -0,0 +1,103 @@ +use chrono::{DateTime, Utc}; +use once_cell::sync::OnceCell; +use std::sync::Mutex; + +pub struct ArpScannerStatus { + pub is_running: bool, + pub scan_started_at: Option>, + pub next_scan_at: Option>, +} + +#[derive(Clone)] +pub struct ArpScannerStatusSnapshot { + pub is_running: bool, + pub scan_started_at: Option>, + pub next_scan_at: Option>, +} + +static STATUS: OnceCell> = OnceCell::new(); + +pub fn init() { + STATUS + .set(Mutex::new(ArpScannerStatus { + is_running: false, + scan_started_at: None, + next_scan_at: None, + })) + .ok(); +} + +pub fn set_running() { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.is_running = true; + s.scan_started_at = Some(Utc::now()); + s.next_scan_at = None; + } +} + +pub fn set_waiting(next_scan_at: DateTime) { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.is_running = false; + s.scan_started_at = None; + s.next_scan_at = Some(next_scan_at); + } +} + +pub fn get() -> Option { + STATUS.get().map(|m| { + let s = m.lock().unwrap(); + ArpScannerStatusSnapshot { + is_running: s.is_running, + scan_started_at: s.scan_started_at, + next_scan_at: s.next_scan_at, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reset_for_test() { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.is_running = false; + s.scan_started_at = None; + s.next_scan_at = None; + } else { + init(); + } + } + + #[test] + fn test_set_running() { + reset_for_test(); + set_running(); + let snapshot = get().unwrap(); + assert!(snapshot.is_running); + assert!(snapshot.scan_started_at.is_some()); + assert!(snapshot.next_scan_at.is_none()); + } + + #[test] + fn test_set_waiting() { + reset_for_test(); + let next = Utc::now() + chrono::Duration::seconds(60); + set_waiting(next); + let snapshot = get().unwrap(); + assert!(!snapshot.is_running); + assert!(snapshot.scan_started_at.is_none()); + assert_eq!(snapshot.next_scan_at.unwrap(), next); + } + + #[test] + fn test_initial_state() { + reset_for_test(); + let snapshot = get().unwrap(); + assert!(!snapshot.is_running); + assert!(snapshot.scan_started_at.is_none()); + assert!(snapshot.next_scan_at.is_none()); + } +} diff --git a/backend/src/device_finders/arp.rs b/backend/src/device_finders/arp.rs index 779d1af..ab359c8 100644 --- a/backend/src/device_finders/arp.rs +++ b/backend/src/device_finders/arp.rs @@ -80,16 +80,16 @@ pub async fn find(interface: String) -> Result, Box Result<(), Box> { // Initialize database db::init_db().await?; + // Initialize ARP scanner status tracking + arp_scanner_status::init(); + // Start the device scanner, web server, and retention cleaner in parallel - tokio::join!(scanner::scan(), web_server::serve(), retention::run()).0 + tokio::join!(arp_scanner::scan(), web_server::serve(), retention::run()).0 } diff --git a/backend/src/settings.rs b/backend/src/settings.rs index ed1432d..aab77ed 100644 --- a/backend/src/settings.rs +++ b/backend/src/settings.rs @@ -22,10 +22,10 @@ pub struct Log { } #[derive(Debug, Deserialize, Clone)] -pub struct Timings { +pub struct ArpScanner { pub wait_between_scans: DurationString, - pub arp_sender_timeout: DurationString, - pub arp_scan_duration: DurationString, + pub sender_timeout: DurationString, + pub scan_duration: DurationString, } #[derive(Debug, Deserialize, Clone)] @@ -66,7 +66,7 @@ pub struct Settings { pub database: Database, pub networking: Networking, pub log: Log, - pub timings: Timings, + pub arp_scanner: ArpScanner, pub notifications: Notifications, pub web_server: WebServer, #[serde(default)] diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index 00b4ae2..a5e07aa 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -4,6 +4,7 @@ use crate::model::device_events::{DeviceEvent, DeviceEventType}; use crate::model::devices::Device; use crate::model::notifications::{Notification, NotificationType}; use crate::settings::get_settings; +use crate::web_server::arp_scanner::ArpScannerStatusResponse; use crate::web_server::devices::RegisterDevicePayload; use axum::extract::Request; use axum::http::StatusCode; @@ -21,6 +22,7 @@ use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa::Modify; use utoipa_swagger_ui::SwaggerUi; +pub mod arp_scanner; pub mod device_events; pub mod devices; pub mod notifications; @@ -45,6 +47,7 @@ pub mod utils; notifications::mark_as_new, notifications::mark_all_as_old, device_events::list, + arp_scanner::status, ), components(schemas( Device, @@ -53,12 +56,14 @@ pub mod utils; RegisterDevicePayload, DeviceEvent, DeviceEventType, + ArpScannerStatusResponse, )), modifiers(&SecurityAddon), tags( (name = "devices", description = "Device management"), (name = "notifications", description = "Notification management"), (name = "device_events", description = "Device event history"), + (name = "arp_scanner", description = "ARP scanner process status"), ) )] struct ApiDoc; @@ -101,6 +106,7 @@ pub async fn serve() -> Result<(), Box> { .route("/api/devices/{mac_address}", delete(devices::unregister)) .route("/api/devices/{mac_address}", get(devices::read)) .route("/api/devices/{mac_address}/events", get(device_events::list)) + .route("/api/arp_scanner/status", get(arp_scanner::status)) .route("/api/notifications", get(notifications::list)) .route( "/api/notifications/mark_all_as_old", diff --git a/backend/src/web_server/arp_scanner.rs b/backend/src/web_server/arp_scanner.rs new file mode 100644 index 0000000..02a1650 --- /dev/null +++ b/backend/src/web_server/arp_scanner.rs @@ -0,0 +1,57 @@ +use axum::{Json, http::StatusCode}; +use chrono::Utc; +use log::error; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::arp_scanner_status; + +#[derive(Serialize, ToSchema)] +pub struct ArpScannerStatusResponse { + pub is_running: bool, + /// Seconds the current scan has been running (only set when is_running is true) + pub running_for_seconds: Option, + /// Seconds until the next scan starts (only set when is_running is false; clamped to 0) + pub next_run_in_seconds: Option, +} + +#[utoipa::path( + get, + path = "/api/arp_scanner/status", + tag = "arp_scanner", + responses( + (status = 200, description = "ARP scanner status", body = ArpScannerStatusResponse), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn status() -> Result, StatusCode> { + let snapshot = match arp_scanner_status::get() { + Some(s) => s, + None => { + error!("ARP scanner status not initialized"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let now = Utc::now(); + + let (running_for_seconds, next_run_in_seconds) = if snapshot.is_running { + let running_for = snapshot + .scan_started_at + .map(|t| (now - t).num_milliseconds() as f64 / 1000.0); + (running_for, None) + } else { + let next_run_in = snapshot.next_scan_at.map(|t| { + let secs = (t - now).num_milliseconds() as f64 / 1000.0; + secs.max(0.0) + }); + (None, next_run_in) + }; + + Ok(Json(ArpScannerStatusResponse { + is_running: snapshot.is_running, + running_for_seconds, + next_run_in_seconds, + })) +} diff --git a/examples/sample_oott.toml b/examples/sample_oott.toml index 5836ecb..29adbe4 100644 --- a/examples/sample_oott.toml +++ b/examples/sample_oott.toml @@ -7,10 +7,10 @@ interface = "eno1" # Network interface to use for scans [log] level = "info" # off, error, warn, info, debug, trace -[timings] +[arp_scanner] wait_between_scans="10m" # 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="10m" # How long to wait for response packets on each scan (5m to 10m is a good timeframe for a class B or C network) +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) +scan_duration="10m" # 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)