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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-28 17:57:26 -04:00
co-authored by Claude Sonnet 4.6
parent 97e4bb1000
commit 020c7d2c49
8 changed files with 193 additions and 15 deletions
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
};
}
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;
}
}
+103
View File
@@ -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<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>,
}
#[derive(Clone)]
pub struct ArpScannerStatusSnapshot {
pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>,
}
static STATUS: OnceCell<Mutex<ArpScannerStatus>> = 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<Utc>) {
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<ArpScannerStatusSnapshot> {
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());
}
}
+4 -4
View File
@@ -80,16 +80,16 @@ pub async fn find(interface: String) -> Result<Vec<Device>, Box<dyn std::error::
let send_interface = network_interface.clone();
// Get timeouts
let sender_timeout: Duration = get_settings().timings.arp_sender_timeout.into();
let sender_timeout: Duration = get_settings().arp_scanner.sender_timeout.into();
info!(
"Sender timeout set to {}",
get_settings().timings.arp_sender_timeout
get_settings().arp_scanner.sender_timeout
);
let scan_duration: Duration = get_settings().timings.arp_scan_duration.into();
let scan_duration: Duration = get_settings().arp_scanner.scan_duration.into();
let receiver_timeout: Duration = scan_duration * 2;
info!(
"Scan duration set to {}",
get_settings().timings.arp_scan_duration
get_settings().arp_scanner.scan_duration
);
info!(
"Receiver timeout set to {}",
+6 -2
View File
@@ -2,6 +2,8 @@ use crate::settings::get_settings;
use clap::Parser;
use log::{LevelFilter, info};
mod arp_scanner;
mod arp_scanner_status;
mod db;
mod device_finders;
mod events;
@@ -9,7 +11,6 @@ mod mac_vendor_finder;
mod vendor_device_type_finder;
mod model;
mod retention;
mod scanner;
mod settings;
mod utils;
mod web_server;
@@ -58,6 +59,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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
}
+4 -4
View File
@@ -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)]
+6
View File
@@ -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<dyn Error>> {
.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",
+57
View File
@@ -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<f64>,
/// Seconds until the next scan starts (only set when is_running is false; clamped to 0)
pub next_run_in_seconds: Option<f64>,
}
#[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<Json<ArpScannerStatusResponse>, 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,
}))
}
+3 -3
View File
@@ -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)