From 5f7a1287a1855c63f9fc0f33ebccada9869d3ffd Mon Sep 17 00:00:00 2001 From: rzuasti Date: Tue, 2 Jun 2026 10:14:50 -0400 Subject: [PATCH] Factor shared scanner logic into scanners/common The five scanners (ARP, SNMP, mDNS, SSDP, DHCP) duplicated their persist-and-notify pipeline, device enrichment, and status state machines across same-family files. Extract the shared logic so a change lands in one place instead of three to five. - scanners/common/pipeline.rs: single record_sighting() persist+notify path, replacing the per-scanner match blocks. ARP/SNMP now use the same merge rules as the passive scanners (keep a stored hostname, never overwrite a known IP with an empty one). - scanners/common/enrichment.rs: build_device() for vendor/device-type lookup with the privacy-MAC service fallback. - scanners/common/{active,passive}_status.rs: the two status state machines plus their tests, written once. Each scanner status.rs is now a thin wrapper over its own static. - utils/network::format_mac(): replaces three identical copies. - web_server/scanner_status.rs: Active/Passive response types and two handler helpers, replacing five near-identical structs+handlers. JSON field names are unchanged so the frontend is unaffected; only OpenAPI schema names change. 27 files changed, ~900 lines net removed. Build, clippy and all 113 tests (4 new) pass. Co-Authored-By: Claude Opus 4.8 --- TODO.md | 2 +- backend/src/scanners.rs | 1 + .../src/scanners/arp/packet_send_receive.rs | 21 +-- backend/src/scanners/arp/scanner.rs | 40 +----- backend/src/scanners/arp/status.rs | 115 ++-------------- backend/src/scanners/common.rs | 10 ++ backend/src/scanners/common/active_status.rs | 104 ++++++++++++++ backend/src/scanners/common/enrichment.rs | 71 ++++++++++ backend/src/scanners/common/passive_status.rs | 112 +++++++++++++++ backend/src/scanners/common/pipeline.rs | 102 ++++++++++++++ backend/src/scanners/dhcp/finder.rs | 86 +++++++++--- backend/src/scanners/dhcp/scanner.rs | 75 ++-------- backend/src/scanners/dhcp/status.rs | 129 +----------------- backend/src/scanners/mdns/scanner.rs | 58 ++------ backend/src/scanners/mdns/status.rs | 129 +----------------- backend/src/scanners/snmp/finder.rs | 41 ++---- backend/src/scanners/snmp/scanner.rs | 39 +----- backend/src/scanners/snmp/status.rs | 115 ++-------------- backend/src/scanners/ssdp/scanner.rs | 60 +------- backend/src/scanners/ssdp/status.rs | 129 +----------------- backend/src/utils/network.rs | 17 +++ backend/src/web_server.rs | 16 +-- backend/src/web_server/arp_scanner.rs | 56 +------- backend/src/web_server/dhcp_scanner.rs | 48 +------ backend/src/web_server/mdns_scanner.rs | 48 +------ backend/src/web_server/scanner_status.rs | 89 ++++++++++++ backend/src/web_server/snmp_scanner.rs | 56 +------- backend/src/web_server/ssdp_scanner.rs | 48 +------ 28 files changed, 692 insertions(+), 1125 deletions(-) create mode 100644 backend/src/scanners/common.rs create mode 100644 backend/src/scanners/common/active_status.rs create mode 100644 backend/src/scanners/common/enrichment.rs create mode 100644 backend/src/scanners/common/passive_status.rs create mode 100644 backend/src/scanners/common/pipeline.rs create mode 100644 backend/src/web_server/scanner_status.rs diff --git a/TODO.md b/TODO.md index 4ea2f75..c30a5b3 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ - [ ] Implement the pushover API call directly to support HTML content and review notification text to use it - [x] Add "devices seen on last scan" to the ARP and SNMP scanners status - [x] Modify the passive scanners status to count devices seen in the last hour -- [ ] Simplify the code of scanners (repeated logic) +- [x] Simplify the code of scanners (repeated logic) - [ ] Modify the event management to ignore duplicate events if they happen within a time threshold - [x] Review the recommended timings for the ARP scanner (change defaults) — SNMP defaults set to 10m/5s diff --git a/backend/src/scanners.rs b/backend/src/scanners.rs index dde60b9..1b264d7 100644 --- a/backend/src/scanners.rs +++ b/backend/src/scanners.rs @@ -1,4 +1,5 @@ pub mod arp; +pub mod common; pub mod dhcp; pub mod error; pub mod mdns; diff --git a/backend/src/scanners/arp/packet_send_receive.rs b/backend/src/scanners/arp/packet_send_receive.rs index 1016b62..eceedcd 100644 --- a/backend/src/scanners/arp/packet_send_receive.rs +++ b/backend/src/scanners/arp/packet_send_receive.rs @@ -1,7 +1,5 @@ -use crate::data::mac_vendor_finder; -use crate::data::vendor_device_type_finder; use crate::model::devices::Device; -use chrono::Local; +use crate::scanners::common::enrichment::build_device; use duration_string::DurationString; use log::{debug, info, trace}; use pnet::datalink::{DataLinkReceiver, DataLinkSender, NetworkInterface}; @@ -99,21 +97,10 @@ pub async fn listen_for_packets( if arp_packet.get_target_proto_addr() == ipv4_net.ip() { let packet_mac_address = arp_packet.get_sender_hw_addr().to_string(); let packet_ip_address = arp_packet.get_sender_proto_addr().to_string(); - let packet_vendor = mac_vendor_finder::find( - packet_mac_address.get(0..8).unwrap_or("").to_string(), - ); - debug!( - "Found online device - IP addr={} - MAC addr={} - vendor={}", - packet_ip_address, packet_mac_address, packet_vendor - ); - let mut device = Device::new( - packet_mac_address, - packet_ip_address, - packet_vendor, - Local::now().to_utc(), - ); - device.device_type = vendor_device_type_finder::find(&device.vendor); + // ARP carries no hostname or service hints, so vendor comes from the OUI only. + let device = build_device(packet_mac_address, packet_ip_address, &[], None); + debug!("Found online device {device}"); devices.push(device); } } diff --git a/backend/src/scanners/arp/scanner.rs b/backend/src/scanners/arp/scanner.rs index 1c9942c..b2e9ff8 100644 --- a/backend/src/scanners/arp/scanner.rs +++ b/backend/src/scanners/arp/scanner.rs @@ -1,8 +1,7 @@ use super::finder; use super::status; -use crate::db; -use crate::events; use crate::model::device_events::DeviceEventScanner; +use crate::scanners::common::pipeline; use crate::settings::get_settings; use chrono::Utc; use log::{debug, info}; @@ -27,42 +26,7 @@ pub async fn scan() -> Result<(), Box> { // Process found devices for device in devices.iter() { debug!("Online device found {}", device); - - // Read device from database - let recorded_device_result = db::devices::read(device.mac_address.clone()); - - match recorded_device_result { - Some(recorded_device) => { - // If it exists update its last seen date - debug!( - "Device found in database {}. Updating to {}.", - recorded_device, device - ); - db::devices::seen( - device.mac_address.clone(), - device.ipv4_address.clone(), - device.vendor.clone(), - device.device_type.clone(), - device.name.clone(), - )?; - events::trigger_existing_device( - recorded_device, - device.clone(), - DeviceEventScanner::Arp, - ) - .ok(); // Ignoring errors here, do not stop loop if notification delivery fails - } - None => { - // If it doesn't exist insert it - debug!( - "Device with MAC address {} not found in database. Inserting it.", - device.mac_address - ); - - db::devices::insert(device.clone())?; - events::trigger_new_device(device.clone(), DeviceEventScanner::Arp).ok(); // Ignoring errors here, do not stop loop if notification delivery fails - } - }; + pipeline::record_sighting(device.clone(), DeviceEventScanner::Arp); } let wait = Duration::from(get_settings().arp_scanner.wait_between_scans); diff --git a/backend/src/scanners/arp/status.rs b/backend/src/scanners/arp/status.rs index 52e3723..77e260e 100644 --- a/backend/src/scanners/arp/status.rs +++ b/backend/src/scanners/arp/status.rs @@ -2,131 +2,32 @@ 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>, - pub last_scan_devices_seen: Option, - pub last_scan_at: Option>, -} +use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus}; -#[derive(Clone)] -pub struct ArpScannerStatusSnapshot { - pub is_running: bool, - pub scan_started_at: Option>, - pub next_scan_at: Option>, - pub last_scan_devices_seen: Option, - pub last_scan_at: Option>, -} - -static STATUS: OnceCell> = OnceCell::new(); +static STATUS: OnceCell> = OnceCell::new(); pub fn init() { - STATUS - .set(Mutex::new(ArpScannerStatus { - is_running: false, - scan_started_at: None, - next_scan_at: None, - last_scan_devices_seen: None, - last_scan_at: None, - })) - .ok(); + STATUS.set(Mutex::new(ActiveStatus::new())).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; + m.lock().unwrap().set_running(); } } 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); + m.lock().unwrap().set_waiting(next_scan_at); } } pub fn record_scan(devices_seen: u64) { if let Some(m) = STATUS.get() { - let mut s = m.lock().unwrap(); - s.last_scan_devices_seen = Some(devices_seen); - s.last_scan_at = Some(Utc::now()); + m.lock().unwrap().record_scan(devices_seen); } } -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, - last_scan_devices_seen: s.last_scan_devices_seen, - last_scan_at: s.last_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; - s.last_scan_devices_seen = None; - s.last_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_record_scan() { - reset_for_test(); - record_scan(7); - let snapshot = get().unwrap(); - assert_eq!(snapshot.last_scan_devices_seen, Some(7)); - assert!(snapshot.last_scan_at.is_some()); - } - - #[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()); - assert!(snapshot.last_scan_devices_seen.is_none()); - assert!(snapshot.last_scan_at.is_none()); - } +pub fn get() -> Option { + STATUS.get().map(|m| m.lock().unwrap().snapshot()) } diff --git a/backend/src/scanners/common.rs b/backend/src/scanners/common.rs new file mode 100644 index 0000000..f33ba9f --- /dev/null +++ b/backend/src/scanners/common.rs @@ -0,0 +1,10 @@ +//! Code shared by all scanners, factored out of the per-protocol modules. +//! +//! - [`pipeline`] persists a sighting and emits the matching event/notification. +//! - [`enrichment`] builds an enriched [`crate::model::devices::Device`] from a MAC + IP. +//! - [`active_status`] / [`passive_status`] hold the two scanner status state machines. + +pub mod active_status; +pub mod enrichment; +pub mod passive_status; +pub mod pipeline; diff --git a/backend/src/scanners/common/active_status.rs b/backend/src/scanners/common/active_status.rs new file mode 100644 index 0000000..0c2b134 --- /dev/null +++ b/backend/src/scanners/common/active_status.rs @@ -0,0 +1,104 @@ +use chrono::{DateTime, Utc}; + +/// Status state for the active (polling) scanners — ARP and SNMP. Each scanner owns its own +/// `OnceCell>` and delegates to these methods (see e.g. +/// [`crate::scanners::arp::status`]). +#[derive(Default)] +pub struct ActiveStatus { + is_running: bool, + scan_started_at: Option>, + next_scan_at: Option>, + last_scan_devices_seen: Option, + last_scan_at: Option>, +} + +/// A point-in-time copy of an [`ActiveStatus`], handed to the API layer. +#[derive(Clone)] +pub struct ActiveSnapshot { + pub is_running: bool, + pub scan_started_at: Option>, + pub next_scan_at: Option>, + pub last_scan_devices_seen: Option, + pub last_scan_at: Option>, +} + +impl ActiveStatus { + pub fn new() -> Self { + Self::default() + } + + /// Mark a scan as in progress. + pub fn set_running(&mut self) { + self.is_running = true; + self.scan_started_at = Some(Utc::now()); + self.next_scan_at = None; + } + + /// Mark the scanner as idle until `next_scan_at`. + pub fn set_waiting(&mut self, next_scan_at: DateTime) { + self.is_running = false; + self.scan_started_at = None; + self.next_scan_at = Some(next_scan_at); + } + + /// Record the result of a completed scan. + pub fn record_scan(&mut self, devices_seen: u64) { + self.last_scan_devices_seen = Some(devices_seen); + self.last_scan_at = Some(Utc::now()); + } + + pub fn snapshot(&self) -> ActiveSnapshot { + ActiveSnapshot { + is_running: self.is_running, + scan_started_at: self.scan_started_at, + next_scan_at: self.next_scan_at, + last_scan_devices_seen: self.last_scan_devices_seen, + last_scan_at: self.last_scan_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_running_marks_in_progress() { + let mut status = ActiveStatus::new(); + status.set_running(); + let snapshot = status.snapshot(); + assert!(snapshot.is_running); + assert!(snapshot.scan_started_at.is_some()); + assert!(snapshot.next_scan_at.is_none()); + } + + #[test] + fn set_waiting_records_next_scan() { + let mut status = ActiveStatus::new(); + let next = Utc::now() + chrono::Duration::seconds(60); + status.set_waiting(next); + let snapshot = status.snapshot(); + assert!(!snapshot.is_running); + assert!(snapshot.scan_started_at.is_none()); + assert_eq!(snapshot.next_scan_at.unwrap(), next); + } + + #[test] + fn record_scan_stores_count_and_time() { + let mut status = ActiveStatus::new(); + status.record_scan(7); + let snapshot = status.snapshot(); + assert_eq!(snapshot.last_scan_devices_seen, Some(7)); + assert!(snapshot.last_scan_at.is_some()); + } + + #[test] + fn initial_state_is_empty() { + let snapshot = ActiveStatus::new().snapshot(); + assert!(!snapshot.is_running); + assert!(snapshot.scan_started_at.is_none()); + assert!(snapshot.next_scan_at.is_none()); + assert!(snapshot.last_scan_devices_seen.is_none()); + assert!(snapshot.last_scan_at.is_none()); + } +} diff --git a/backend/src/scanners/common/enrichment.rs b/backend/src/scanners/common/enrichment.rs new file mode 100644 index 0000000..07efdef --- /dev/null +++ b/backend/src/scanners/common/enrichment.rs @@ -0,0 +1,71 @@ +use chrono::Local; + +use crate::data::mac_vendor_finder; +use crate::data::service_vendor_finder; +use crate::data::vendor_device_type_finder; +use crate::model::devices::Device; +use crate::utils::network; + +/// Build an enriched [`Device`] from a discovered MAC and IPv4 address, deducing its vendor and +/// device type. +/// +/// The vendor is resolved from the MAC's OUI. Privacy/locally-administered MACs have no real OUI, +/// so when that lookup fails the vendor is taken from the device's advertised `service_hints` +/// (e.g. mDNS service types or SSDP NT URNs); pass an empty slice when the protocol offers none. +/// An empty IPv4 address is allowed (e.g. a DHCP DISCOVER carries no assigned address). +pub fn build_device( + mac: String, + ipv4: String, + service_hints: &[String], + name: Option, +) -> Device { + let mut vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string()); + if vendor.is_empty() && network::is_locally_administered(&mac) { + vendor = service_vendor_finder::find(service_hints); + } + + let mut device = Device::new(mac, ipv4, vendor, Local::now().to_utc()); + device.device_type = vendor_device_type_finder::find(&device.vendor); + device.name = name; + device +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn privacy_mac_falls_back_to_service_vendor() { + // A locally-administered (0x02 bit set) MAC has no real OUI, so the vendor is taken from + // the advertised service hint instead. + let services = vec!["_companion-link._tcp.local".to_string()]; + let device = build_device( + "02:11:22:33:44:55".to_string(), + "192.168.1.10".to_string(), + &services, + Some("my-host".to_string()), + ); + + assert_eq!(device.vendor, "Apple, Inc."); + assert_eq!(device.mac_address, "02:11:22:33:44:55"); + assert_eq!(device.ipv4_address, "192.168.1.10"); + assert_eq!(device.name, Some("my-host".to_string())); + } + + #[test] + fn service_fallback_is_skipped_for_globally_administered_macs() { + // The service hint would resolve to Apple, but the fallback only applies to + // locally-administered MACs, so a globally administered one must not pick it up. + let services = vec!["_companion-link._tcp.local".to_string()]; + let device = build_device( + "00:11:22:33:44:55".to_string(), + String::new(), + &services, + None, + ); + + assert_ne!(device.vendor, "Apple, Inc."); + assert!(device.ipv4_address.is_empty()); + assert!(device.name.is_none()); + } +} diff --git a/backend/src/scanners/common/passive_status.rs b/backend/src/scanners/common/passive_status.rs new file mode 100644 index 0000000..9e235d4 --- /dev/null +++ b/backend/src/scanners/common/passive_status.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Duration, Utc}; + +/// A device counts as "seen" only if its most recent sighting falls within this rolling window. +const RECENT_WINDOW_SECONDS: i64 = 3600; + +/// Status state for the passive (listening) scanners — mDNS, SSDP and DHCP. Each scanner owns its +/// own `OnceCell>` and delegates to these methods (see e.g. +/// [`crate::scanners::mdns::status`]). +#[derive(Default)] +pub struct PassiveStatus { + is_listening: bool, + listening_since: Option>, + /// Most recent sighting time per device MAC, used to count the distinct devices seen within + /// the last hour. + recent_sightings: HashMap>, + last_discovery_at: Option>, +} + +/// A point-in-time copy of a [`PassiveStatus`], handed to the API layer. +#[derive(Clone)] +pub struct PassiveSnapshot { + pub is_listening: bool, + pub listening_since: Option>, + /// Distinct devices seen within the last hour. + pub devices_last_hour: u64, + pub last_discovery_at: Option>, +} + +impl PassiveStatus { + pub fn new() -> Self { + Self::default() + } + + pub fn set_listening(&mut self) { + self.is_listening = true; + self.listening_since = Some(Utc::now()); + } + + pub fn record_discovery(&mut self, mac: &str) { + let now = Utc::now(); + self.recent_sightings.insert(mac.to_string(), now); + self.last_discovery_at = Some(now); + } + + /// Snapshot the status, first pruning sightings older than the rolling window so the + /// distinct-device count only reflects the last hour. + pub fn snapshot(&mut self) -> PassiveSnapshot { + let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS); + self.recent_sightings.retain(|_, seen| *seen >= cutoff); + PassiveSnapshot { + is_listening: self.is_listening, + listening_since: self.listening_since, + devices_last_hour: self.recent_sightings.len() as u64, + last_discovery_at: self.last_discovery_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_listening_marks_active() { + let mut status = PassiveStatus::new(); + status.set_listening(); + let snapshot = status.snapshot(); + assert!(snapshot.is_listening); + assert!(snapshot.listening_since.is_some()); + } + + #[test] + fn same_mac_counts_once() { + let mut status = PassiveStatus::new(); + status.record_discovery("aa:bb:cc:dd:ee:ff"); + status.record_discovery("aa:bb:cc:dd:ee:ff"); + let snapshot = status.snapshot(); + assert_eq!(snapshot.devices_last_hour, 1); + assert!(snapshot.last_discovery_at.is_some()); + } + + #[test] + fn distinct_macs_counted() { + let mut status = PassiveStatus::new(); + status.record_discovery("aa:bb:cc:dd:ee:ff"); + status.record_discovery("11:22:33:44:55:66"); + assert_eq!(status.snapshot().devices_last_hour, 2); + } + + #[test] + fn old_sighting_excluded() { + let mut status = PassiveStatus::new(); + status.record_discovery("aa:bb:cc:dd:ee:ff"); + // Backdate an entry beyond the window; it must not be counted. + status.recent_sightings.insert( + "11:22:33:44:55:66".to_string(), + Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60), + ); + assert_eq!(status.snapshot().devices_last_hour, 1); + } + + #[test] + fn initial_state_is_empty() { + let snapshot = PassiveStatus::new().snapshot(); + assert!(!snapshot.is_listening); + assert!(snapshot.listening_since.is_none()); + assert_eq!(snapshot.devices_last_hour, 0); + assert!(snapshot.last_discovery_at.is_none()); + } +} diff --git a/backend/src/scanners/common/pipeline.rs b/backend/src/scanners/common/pipeline.rs new file mode 100644 index 0000000..38f8a92 --- /dev/null +++ b/backend/src/scanners/common/pipeline.rs @@ -0,0 +1,102 @@ +use log::{debug, error}; + +use crate::db; +use crate::events; +use crate::model::device_events::DeviceEventScanner; +use crate::model::devices::Device; + +/// Persist a device sighting and emit the matching event/notification, feeding every scanner +/// (ARP, mDNS, SSDP, DHCP, SNMP) through one code path. +/// +/// When the device is already known, the sighting is reconciled with the stored record: +/// - a previously stored hostname is kept rather than overwritten by this sighting's name; +/// - a known IP address is kept when this sighting carries none (an empty string), so a DHCP +/// DISCOVER (which has no assigned IP) never clobbers a good address. +/// +/// Errors are logged and swallowed: a scan/listen loop must never stop because a single sighting +/// failed to persist or a notification could not be delivered. +pub fn record_sighting(mut device: Device, scanner: DeviceEventScanner) { + match db::devices::read(device.mac_address.clone()) { + Some(recorded) => { + debug!("Sighting of known device {}; updating", device.mac_address); + if recorded.name.is_some() { + device.name = recorded.name.clone(); + } + if device.ipv4_address.is_empty() { + device.ipv4_address = recorded.ipv4_address.clone(); + } + if let Err(err) = db::devices::seen( + device.mac_address.clone(), + device.ipv4_address.clone(), + device.vendor.clone(), + device.device_type.clone(), + device.name.clone(), + ) { + error!("Failed to update device {}: {err}", device.mac_address); + return; + } + // Ignoring errors: do not stop the loop if notification delivery fails. + events::trigger_existing_device(recorded, device, scanner).ok(); + } + None => { + debug!("New device {} discovered; inserting", device.mac_address); + if let Err(err) = db::devices::insert(device.clone()) { + error!("Failed to insert device {}: {err}", device.mac_address); + return; + } + // Ignoring errors: do not stop the loop if notification delivery fails. + events::trigger_new_device(device, scanner).ok(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests_common; + use chrono::Utc; + + #[tokio::test] + async fn inserts_an_unknown_device() { + tests_common::setup().await; + let mac = "de:ad:be:ef:00:01".to_string(); + + let mut device = Device::new( + mac.clone(), + "192.168.9.1".to_string(), + "Acme".to_string(), + Utc::now(), + ); + device.name = Some("printer".to_string()); + + record_sighting(device, DeviceEventScanner::Arp); + + let stored = db::devices::read(mac).expect("device should have been inserted"); + assert_eq!(stored.ipv4_address, "192.168.9.1"); + assert_eq!(stored.name, Some("printer".to_string())); + } + + #[tokio::test] + async fn known_device_keeps_stored_name_and_ip() { + tests_common::setup().await; + let mac = "de:ad:be:ef:00:02".to_string(); + + // First sighting records a hostname and an IP. + let mut first = Device::new( + mac.clone(), + "192.168.9.2".to_string(), + "Acme".to_string(), + Utc::now(), + ); + first.name = Some("nas".to_string()); + record_sighting(first, DeviceEventScanner::Mdns); + + // A later sighting with no name and no IP (e.g. a DHCP DISCOVER) must not clobber them. + let later = Device::new(mac.clone(), String::new(), String::new(), Utc::now()); + record_sighting(later, DeviceEventScanner::Dhcp); + + let stored = db::devices::read(mac).expect("device should exist"); + assert_eq!(stored.name, Some("nas".to_string())); + assert_eq!(stored.ipv4_address, "192.168.9.2"); + } +} diff --git a/backend/src/scanners/dhcp/finder.rs b/backend/src/scanners/dhcp/finder.rs index a4e89c9..5580ac4 100644 --- a/backend/src/scanners/dhcp/finder.rs +++ b/backend/src/scanners/dhcp/finder.rs @@ -4,6 +4,8 @@ use log::{debug, info}; use socket2::{Domain, Protocol, Socket, Type}; use tokio::net::UdpSocket; +use crate::utils::network::format_mac; + const DHCP_SERVER_PORT: u16 = 67; // DHCP/BOOTP fixed-header field offsets (RFC 2131). @@ -162,15 +164,6 @@ pub fn parse_packet(buf: &[u8]) -> Option { }) } -/// Format 6 raw MAC bytes as a lowercase colon-separated string. -fn format_mac(bytes: &[u8]) -> String { - bytes - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(":") -} - #[cfg(test)] mod tests { use super::*; @@ -184,7 +177,8 @@ mod tests { let ci = ciaddr.octets(); buf[CIADDR_OFFSET..CIADDR_OFFSET + 4].copy_from_slice(&ci); // chaddr: aa:bb:cc:dd:ee:ff - buf[CHADDR_OFFSET..CHADDR_OFFSET + 6].copy_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); + buf[CHADDR_OFFSET..CHADDR_OFFSET + 6] + .copy_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); buf[MAGIC_COOKIE_OFFSET..OPTIONS_OFFSET].copy_from_slice(&MAGIC_COOKIE); buf.extend_from_slice(options); buf.push(OPTION_END); @@ -201,7 +195,13 @@ mod tests { options.extend_from_slice(&[OPTION_HOSTNAME, 6, b'l', b'a', b'p', b't', b'o', b'p']); options.extend_from_slice(&[OPTION_REQUESTED_IP, 4, 192, 168, 1, 50]); - let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let buf = build_packet( + OP_BOOTREQUEST, + HTYPE_ETHERNET, + HLEN_ETHERNET, + Ipv4Addr::UNSPECIFIED, + &options, + ); let parsed = parse_packet(&buf).expect("discover packet"); assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff"); assert_eq!(parsed.hostname.as_deref(), Some("laptop")); @@ -229,7 +229,13 @@ mod tests { #[test] fn test_parse_discover_without_ip_yields_none_hint() { let options = message_type_option(DHCP_DISCOVER); - let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let buf = build_packet( + OP_BOOTREQUEST, + HTYPE_ETHERNET, + HLEN_ETHERNET, + Ipv4Addr::UNSPECIFIED, + &options, + ); let parsed = parse_packet(&buf).expect("discover packet"); assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff"); assert_eq!(parsed.hostname, None); @@ -240,7 +246,13 @@ mod tests { fn test_parse_server_reply_returns_none() { // op = 2 (BOOTREPLY) — a server message, ignored. let options = message_type_option(DHCP_DISCOVER); - let buf = build_packet(2, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let buf = build_packet( + 2, + HTYPE_ETHERNET, + HLEN_ETHERNET, + Ipv4Addr::UNSPECIFIED, + &options, + ); assert!(parse_packet(&buf).is_none()); } @@ -248,7 +260,13 @@ mod tests { fn test_parse_other_message_type_returns_none() { // message type 5 = ACK (server->client), not a client request. let options = message_type_option(5); - let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let buf = build_packet( + OP_BOOTREQUEST, + HTYPE_ETHERNET, + HLEN_ETHERNET, + Ipv4Addr::UNSPECIFIED, + &options, + ); assert!(parse_packet(&buf).is_none()); } @@ -256,14 +274,26 @@ mod tests { fn test_parse_missing_message_type_returns_none() { // No option 53 present at all. let options = vec![OPTION_HOSTNAME, 4, b'h', b'o', b's', b't']; - let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let buf = build_packet( + OP_BOOTREQUEST, + HTYPE_ETHERNET, + HLEN_ETHERNET, + Ipv4Addr::UNSPECIFIED, + &options, + ); assert!(parse_packet(&buf).is_none()); } #[test] fn test_parse_bad_magic_cookie_returns_none() { let options = message_type_option(DHCP_DISCOVER); - let mut buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let mut buf = build_packet( + OP_BOOTREQUEST, + HTYPE_ETHERNET, + HLEN_ETHERNET, + Ipv4Addr::UNSPECIFIED, + &options, + ); buf[MAGIC_COOKIE_OFFSET] = 0; assert!(parse_packet(&buf).is_none()); } @@ -272,10 +302,22 @@ mod tests { fn test_parse_non_ethernet_returns_none() { let options = message_type_option(DHCP_DISCOVER); // htype != 1 - let buf = build_packet(OP_BOOTREQUEST, 6, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let buf = build_packet( + OP_BOOTREQUEST, + 6, + HLEN_ETHERNET, + Ipv4Addr::UNSPECIFIED, + &options, + ); assert!(parse_packet(&buf).is_none()); // hlen != 6 - let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, 8, Ipv4Addr::UNSPECIFIED, &options); + let buf = build_packet( + OP_BOOTREQUEST, + HTYPE_ETHERNET, + 8, + Ipv4Addr::UNSPECIFIED, + &options, + ); assert!(parse_packet(&buf).is_none()); } @@ -290,7 +332,13 @@ mod tests { // Option claims 4 bytes of payload but the buffer ends early. Must not panic and // must not yield a discovery (message type never read). let options = vec![OPTION_REQUESTED_IP, 4, 192, 168]; - let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let buf = build_packet( + OP_BOOTREQUEST, + HTYPE_ETHERNET, + HLEN_ETHERNET, + Ipv4Addr::UNSPECIFIED, + &options, + ); // Drop the trailing END byte appended by build_packet to keep the option truncated. let truncated = &buf[..buf.len() - 1]; assert!(parse_packet(truncated).is_none()); diff --git a/backend/src/scanners/dhcp/scanner.rs b/backend/src/scanners/dhcp/scanner.rs index 7cb57f2..d55204e 100644 --- a/backend/src/scanners/dhcp/scanner.rs +++ b/backend/src/scanners/dhcp/scanner.rs @@ -1,14 +1,10 @@ -use chrono::Local; -use log::{debug, error, info, warn}; +use log::{info, warn}; use super::finder; use super::status; -use crate::data::mac_vendor_finder; -use crate::data::vendor_device_type_finder; -use crate::db; -use crate::events; use crate::model::device_events::DeviceEventScanner; -use crate::model::devices::Device; +use crate::scanners::common::enrichment::build_device; +use crate::scanners::common::pipeline; use crate::settings::get_settings; /// Passively snoop DHCP DISCOVER/REQUEST broadcasts and feed discovered devices into the @@ -40,64 +36,23 @@ pub async fn listen() -> Result<(), Box> { None => continue, // server reply, non-Ethernet, other message type, garbage }; - process_discovery(discovery).await; + process_discovery(discovery); } } -async fn process_discovery(discovery: finder::DhcpDiscovery) { +fn process_discovery(discovery: finder::DhcpDiscovery) { let mac = discovery.mac; - // Unlike mDNS/SSDP, the MAC is carried in the packet (chaddr), so no ARP probe is - // needed. Privacy/locally-administered MACs have no real OUI, so the lookup returns - // an empty vendor; there is no DHCP equivalent of a service list to fall back on, so - // it is left empty (the DB layer preserves any previously known vendor). - let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string()); - - let ip = discovery.ip_hint.map(|ip| ip.to_string()); - let mut device = Device::new( - mac.clone(), - ip.clone().unwrap_or_default(), - vendor, - Local::now().to_utc(), - ); - device.device_type = vendor_device_type_finder::find(&device.vendor); - device.name = discovery.hostname; - - match db::devices::read(mac.clone()) { - Some(recorded) => { - debug!("DHCP sighting of known device {mac}; updating"); - // Keep a previously stored name (likely a richer hostname from mDNS) rather - // than overwriting it. - if recorded.name.is_some() { - device.name = recorded.name.clone(); - } - // A DISCOVER carries no assigned IP. Don't clobber a known good address with an - // empty string; reuse the recorded one when this packet has no IP hint. - if ip.is_none() { - device.ipv4_address = recorded.ipv4_address.clone(); - } - if let Err(err) = db::devices::seen( - device.mac_address.clone(), - device.ipv4_address.clone(), - device.vendor.clone(), - device.device_type.clone(), - device.name.clone(), - ) { - error!("Failed to update DHCP device {mac}: {err}"); - return; - } - // Ignoring errors: do not stop the listener if notification delivery fails - events::trigger_existing_device(recorded, device, DeviceEventScanner::Dhcp).ok(); - } - None => { - debug!("New device {mac} discovered via DHCP; inserting"); - if let Err(err) = db::devices::insert(device.clone()) { - error!("Failed to insert DHCP device {mac}: {err}"); - return; - } - events::trigger_new_device(device, DeviceEventScanner::Dhcp).ok(); - } - } + // Unlike mDNS/SSDP, the MAC is carried in the packet (chaddr), so no ARP probe is needed. + // DHCP advertises no service list, so there is no vendor fallback (pass no service hints); a + // privacy MAC therefore yields an empty vendor, which the DB layer preserves. A DISCOVER + // carries no assigned IP, so the address may be empty here — the pipeline keeps any known IP. + let ipv4 = discovery + .ip_hint + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let device = build_device(mac.clone(), ipv4, &[], discovery.hostname); + pipeline::record_sighting(device, DeviceEventScanner::Dhcp); status::record_discovery(&mac); } diff --git a/backend/src/scanners/dhcp/status.rs b/backend/src/scanners/dhcp/status.rs index f8f498e..f4dc1f5 100644 --- a/backend/src/scanners/dhcp/status.rs +++ b/backend/src/scanners/dhcp/status.rs @@ -1,141 +1,26 @@ -use chrono::{DateTime, Duration, Utc}; use once_cell::sync::OnceCell; -use std::collections::HashMap; use std::sync::Mutex; -/// A device counts as "seen" only if its most recent sighting falls within this -/// rolling window. -const RECENT_WINDOW_SECONDS: i64 = 3600; +use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus}; -pub struct DhcpScannerStatus { - pub is_listening: bool, - pub listening_since: Option>, - /// Most recent sighting time per device MAC, used to count the distinct - /// devices seen within the last hour. - pub recent_sightings: HashMap>, - pub last_discovery_at: Option>, -} - -#[derive(Clone)] -pub struct DhcpScannerStatusSnapshot { - pub is_listening: bool, - pub listening_since: Option>, - /// Distinct devices seen within the last hour. - pub devices_last_hour: u64, - pub last_discovery_at: Option>, -} - -static STATUS: OnceCell> = OnceCell::new(); +static STATUS: OnceCell> = OnceCell::new(); pub fn init() { - STATUS - .set(Mutex::new(DhcpScannerStatus { - is_listening: false, - listening_since: None, - recent_sightings: HashMap::new(), - last_discovery_at: None, - })) - .ok(); + STATUS.set(Mutex::new(PassiveStatus::new())).ok(); } pub fn set_listening() { if let Some(m) = STATUS.get() { - let mut s = m.lock().unwrap(); - s.is_listening = true; - s.listening_since = Some(Utc::now()); + m.lock().unwrap().set_listening(); } } pub fn record_discovery(mac: &str) { if let Some(m) = STATUS.get() { - let now = Utc::now(); - let mut s = m.lock().unwrap(); - s.recent_sightings.insert(mac.to_string(), now); - s.last_discovery_at = Some(now); + m.lock().unwrap().record_discovery(mac); } } -pub fn get() -> Option { - STATUS.get().map(|m| { - let mut s = m.lock().unwrap(); - let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS); - s.recent_sightings.retain(|_, seen| *seen >= cutoff); - DhcpScannerStatusSnapshot { - is_listening: s.is_listening, - listening_since: s.listening_since, - devices_last_hour: s.recent_sightings.len() as u64, - last_discovery_at: s.last_discovery_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_listening = false; - s.listening_since = None; - s.recent_sightings.clear(); - s.last_discovery_at = None; - } else { - init(); - } - } - - #[test] - fn test_set_listening() { - reset_for_test(); - set_listening(); - let snapshot = get().unwrap(); - assert!(snapshot.is_listening); - assert!(snapshot.listening_since.is_some()); - } - - #[test] - fn test_same_mac_counts_once() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - record_discovery("aa:bb:cc:dd:ee:ff"); - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 1); - assert!(snapshot.last_discovery_at.is_some()); - } - - #[test] - fn test_distinct_macs_counted() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - record_discovery("11:22:33:44:55:66"); - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 2); - } - - #[test] - fn test_old_sighting_excluded() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - // Backdate an entry beyond the window; it must not be counted. - if let Some(m) = STATUS.get() { - let mut s = m.lock().unwrap(); - s.recent_sightings.insert( - "11:22:33:44:55:66".to_string(), - Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60), - ); - } - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 1); - } - - #[test] - fn test_initial_state() { - reset_for_test(); - let snapshot = get().unwrap(); - assert!(!snapshot.is_listening); - assert!(snapshot.listening_since.is_none()); - assert_eq!(snapshot.devices_last_hour, 0); - assert!(snapshot.last_discovery_at.is_none()); - } +pub fn get() -> Option { + STATUS.get().map(|m| m.lock().unwrap().snapshot()) } diff --git a/backend/src/scanners/mdns/scanner.rs b/backend/src/scanners/mdns/scanner.rs index f049f06..00e6ad1 100644 --- a/backend/src/scanners/mdns/scanner.rs +++ b/backend/src/scanners/mdns/scanner.rs @@ -1,17 +1,13 @@ use std::net::{IpAddr, Ipv4Addr}; use std::time::Duration; -use chrono::Local; -use log::{debug, error, info, warn}; +use log::{debug, info, warn}; use super::finder; use super::status; -use crate::data::mac_vendor_finder; -use crate::data::vendor_device_type_finder; -use crate::db; -use crate::events; use crate::model::device_events::DeviceEventScanner; -use crate::model::devices::Device; +use crate::scanners::common::enrichment::build_device; +use crate::scanners::common::pipeline; use crate::settings::get_settings; /// Passively listen for mDNS/Bonjour announcements and feed discovered devices into the same @@ -77,51 +73,15 @@ async fn process_announcement( } }; - let mut vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string()); - // Privacy MACs are locally administered and have no real OUI, so the lookup above fails. - // Fall back to the vendor-specific mDNS services the device advertises. - if vendor.is_empty() && crate::utils::network::is_locally_administered(&mac) { - vendor = crate::data::service_vendor_finder::find(&service_types); - } - let mut device = Device::new( + // The advertised mDNS service types let build_device deduce a vendor for privacy MACs whose + // OUI lookup fails. + let device = build_device( mac.clone(), src_ip.to_string(), - vendor, - Local::now().to_utc(), + &service_types, + Some(hostname), ); - device.device_type = vendor_device_type_finder::find(&device.vendor); - device.name = Some(hostname); - - match db::devices::read(mac.clone()) { - Some(recorded) => { - debug!("mDNS sighting of known device {mac}; updating"); - // Keep the previously stored hostname rather than overwriting it with this - // announcement's hostname. - if recorded.name.is_some() { - device.name = recorded.name.clone(); - } - if let Err(err) = db::devices::seen( - device.mac_address.clone(), - device.ipv4_address.clone(), - device.vendor.clone(), - device.device_type.clone(), - device.name.clone(), - ) { - error!("Failed to update mDNS device {mac}: {err}"); - return; - } - // Ignoring errors: do not stop the listener if notification delivery fails - events::trigger_existing_device(recorded, device, DeviceEventScanner::Mdns).ok(); - } - None => { - debug!("New device {mac} discovered via mDNS; inserting"); - if let Err(err) = db::devices::insert(device.clone()) { - error!("Failed to insert mDNS device {mac}: {err}"); - return; - } - events::trigger_new_device(device, DeviceEventScanner::Mdns).ok(); - } - } + pipeline::record_sighting(device, DeviceEventScanner::Mdns); status::record_discovery(&mac); } diff --git a/backend/src/scanners/mdns/status.rs b/backend/src/scanners/mdns/status.rs index 2cd0e23..f4dc1f5 100644 --- a/backend/src/scanners/mdns/status.rs +++ b/backend/src/scanners/mdns/status.rs @@ -1,141 +1,26 @@ -use chrono::{DateTime, Duration, Utc}; use once_cell::sync::OnceCell; -use std::collections::HashMap; use std::sync::Mutex; -/// A device counts as "seen" only if its most recent sighting falls within this -/// rolling window. -const RECENT_WINDOW_SECONDS: i64 = 3600; +use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus}; -pub struct MdnsScannerStatus { - pub is_listening: bool, - pub listening_since: Option>, - /// Most recent sighting time per device MAC, used to count the distinct - /// devices seen within the last hour. - pub recent_sightings: HashMap>, - pub last_discovery_at: Option>, -} - -#[derive(Clone)] -pub struct MdnsScannerStatusSnapshot { - pub is_listening: bool, - pub listening_since: Option>, - /// Distinct devices seen within the last hour. - pub devices_last_hour: u64, - pub last_discovery_at: Option>, -} - -static STATUS: OnceCell> = OnceCell::new(); +static STATUS: OnceCell> = OnceCell::new(); pub fn init() { - STATUS - .set(Mutex::new(MdnsScannerStatus { - is_listening: false, - listening_since: None, - recent_sightings: HashMap::new(), - last_discovery_at: None, - })) - .ok(); + STATUS.set(Mutex::new(PassiveStatus::new())).ok(); } pub fn set_listening() { if let Some(m) = STATUS.get() { - let mut s = m.lock().unwrap(); - s.is_listening = true; - s.listening_since = Some(Utc::now()); + m.lock().unwrap().set_listening(); } } pub fn record_discovery(mac: &str) { if let Some(m) = STATUS.get() { - let now = Utc::now(); - let mut s = m.lock().unwrap(); - s.recent_sightings.insert(mac.to_string(), now); - s.last_discovery_at = Some(now); + m.lock().unwrap().record_discovery(mac); } } -pub fn get() -> Option { - STATUS.get().map(|m| { - let mut s = m.lock().unwrap(); - let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS); - s.recent_sightings.retain(|_, seen| *seen >= cutoff); - MdnsScannerStatusSnapshot { - is_listening: s.is_listening, - listening_since: s.listening_since, - devices_last_hour: s.recent_sightings.len() as u64, - last_discovery_at: s.last_discovery_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_listening = false; - s.listening_since = None; - s.recent_sightings.clear(); - s.last_discovery_at = None; - } else { - init(); - } - } - - #[test] - fn test_set_listening() { - reset_for_test(); - set_listening(); - let snapshot = get().unwrap(); - assert!(snapshot.is_listening); - assert!(snapshot.listening_since.is_some()); - } - - #[test] - fn test_same_mac_counts_once() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - record_discovery("aa:bb:cc:dd:ee:ff"); - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 1); - assert!(snapshot.last_discovery_at.is_some()); - } - - #[test] - fn test_distinct_macs_counted() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - record_discovery("11:22:33:44:55:66"); - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 2); - } - - #[test] - fn test_old_sighting_excluded() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - // Backdate an entry beyond the window; it must not be counted. - if let Some(m) = STATUS.get() { - let mut s = m.lock().unwrap(); - s.recent_sightings.insert( - "11:22:33:44:55:66".to_string(), - Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60), - ); - } - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 1); - } - - #[test] - fn test_initial_state() { - reset_for_test(); - let snapshot = get().unwrap(); - assert!(!snapshot.is_listening); - assert!(snapshot.listening_since.is_none()); - assert_eq!(snapshot.devices_last_hour, 0); - assert!(snapshot.last_discovery_at.is_none()); - } +pub fn get() -> Option { + STATUS.get().map(|m| m.lock().unwrap().snapshot()) } diff --git a/backend/src/scanners/snmp/finder.rs b/backend/src/scanners/snmp/finder.rs index 23a1985..12feaa9 100644 --- a/backend/src/scanners/snmp/finder.rs +++ b/backend/src/scanners/snmp/finder.rs @@ -1,13 +1,12 @@ use std::net::{Ipv4Addr, SocketAddr}; -use chrono::Local; use csnmp::{ObjectIdentifier, ObjectValue, Snmp2cClient}; use log::{debug, info, warn}; -use crate::data::mac_vendor_finder; -use crate::data::vendor_device_type_finder; use crate::model::devices::Device; +use crate::scanners::common::enrichment::build_device; use crate::settings::SnmpScanner; +use crate::utils::network::format_mac; // ipNetToMediaPhysAddress (RFC 1213 MIB-II): maps interface + IPv4 address to a MAC. Walking // this column yields one row per neighbour in the agent's ARP cache. Rows are indexed by @@ -43,12 +42,7 @@ pub async fn find(config: &SnmpScanner) -> Result, Box String { - bytes - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(":") -} - #[cfg(test)] mod tests { use super::*; @@ -141,7 +126,10 @@ mod tests { let pairs = parse_arp_table(rows, &base()); assert_eq!( pairs, - vec![("aa:bb:cc:dd:ee:ff".to_string(), Ipv4Addr::new(192, 168, 1, 50))] + vec![( + "aa:bb:cc:dd:ee:ff".to_string(), + Ipv4Addr::new(192, 168, 1, 50) + )] ); } @@ -180,12 +168,13 @@ mod tests { let pairs = parse_arp_table(rows, &base()); assert_eq!(pairs.len(), 2); - assert!(pairs.contains(&("00:11:22:33:44:55".to_string(), Ipv4Addr::new(192, 168, 1, 2)))); - assert!(pairs.contains(&("66:77:88:99:aa:bb".to_string(), Ipv4Addr::new(192, 168, 1, 3)))); - } - - #[test] - fn format_mac_pads_and_lowercases() { - assert_eq!(format_mac(&[0x0a, 0x00, 0xff, 0x10, 0x20, 0x30]), "0a:00:ff:10:20:30"); + assert!(pairs.contains(&( + "00:11:22:33:44:55".to_string(), + Ipv4Addr::new(192, 168, 1, 2) + ))); + assert!(pairs.contains(&( + "66:77:88:99:aa:bb".to_string(), + Ipv4Addr::new(192, 168, 1, 3) + ))); } } diff --git a/backend/src/scanners/snmp/scanner.rs b/backend/src/scanners/snmp/scanner.rs index 15bbf4d..732860a 100644 --- a/backend/src/scanners/snmp/scanner.rs +++ b/backend/src/scanners/snmp/scanner.rs @@ -1,11 +1,10 @@ use super::finder; use super::status; -use crate::db; -use crate::events; use crate::model::device_events::DeviceEventScanner; +use crate::scanners::common::pipeline; use crate::settings::get_settings; use chrono::Utc; -use log::{debug, error, info}; +use log::{error, info}; use tokio::time::{Duration, sleep}; /// Periodically poll an SNMP agent (typically the gateway) for its ARP/neighbour cache and feed @@ -35,7 +34,7 @@ pub async fn scan() -> Result<(), Box> { info!("SNMP poll found {} devices in the ARP cache", devices.len()); status::record_scan(devices.len() as u64); for device in devices.iter() { - process_device(device); + pipeline::record_sighting(device.clone(), DeviceEventScanner::Snmp); } } Err(err) => error!("SNMP poll of {} failed: {err}", config.target), @@ -51,35 +50,3 @@ pub async fn scan() -> Result<(), Box> { sleep(wait).await; } } - -fn process_device(device: &crate::model::devices::Device) { - match db::devices::read(device.mac_address.clone()) { - Some(recorded_device) => { - debug!("SNMP sighting of known device {}; updating", device.mac_address); - if let Err(err) = db::devices::seen( - device.mac_address.clone(), - device.ipv4_address.clone(), - device.vendor.clone(), - device.device_type.clone(), - device.name.clone(), - ) { - error!("Failed to update SNMP device {}: {err}", device.mac_address); - return; - } - // Ignoring errors: do not stop the loop if notification delivery fails. - events::trigger_existing_device(recorded_device, device.clone(), DeviceEventScanner::Snmp) - .ok(); - } - None => { - debug!( - "New device {} discovered via SNMP; inserting", - device.mac_address - ); - if let Err(err) = db::devices::insert(device.clone()) { - error!("Failed to insert SNMP device {}: {err}", device.mac_address); - return; - } - events::trigger_new_device(device.clone(), DeviceEventScanner::Snmp).ok(); - } - } -} diff --git a/backend/src/scanners/snmp/status.rs b/backend/src/scanners/snmp/status.rs index b8eaa7e..77e260e 100644 --- a/backend/src/scanners/snmp/status.rs +++ b/backend/src/scanners/snmp/status.rs @@ -2,131 +2,32 @@ use chrono::{DateTime, Utc}; use once_cell::sync::OnceCell; use std::sync::Mutex; -pub struct SnmpScannerStatus { - pub is_running: bool, - pub scan_started_at: Option>, - pub next_scan_at: Option>, - pub last_scan_devices_seen: Option, - pub last_scan_at: Option>, -} +use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus}; -#[derive(Clone)] -pub struct SnmpScannerStatusSnapshot { - pub is_running: bool, - pub scan_started_at: Option>, - pub next_scan_at: Option>, - pub last_scan_devices_seen: Option, - pub last_scan_at: Option>, -} - -static STATUS: OnceCell> = OnceCell::new(); +static STATUS: OnceCell> = OnceCell::new(); pub fn init() { - STATUS - .set(Mutex::new(SnmpScannerStatus { - is_running: false, - scan_started_at: None, - next_scan_at: None, - last_scan_devices_seen: None, - last_scan_at: None, - })) - .ok(); + STATUS.set(Mutex::new(ActiveStatus::new())).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; + m.lock().unwrap().set_running(); } } 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); + m.lock().unwrap().set_waiting(next_scan_at); } } pub fn record_scan(devices_seen: u64) { if let Some(m) = STATUS.get() { - let mut s = m.lock().unwrap(); - s.last_scan_devices_seen = Some(devices_seen); - s.last_scan_at = Some(Utc::now()); + m.lock().unwrap().record_scan(devices_seen); } } -pub fn get() -> Option { - STATUS.get().map(|m| { - let s = m.lock().unwrap(); - SnmpScannerStatusSnapshot { - is_running: s.is_running, - scan_started_at: s.scan_started_at, - next_scan_at: s.next_scan_at, - last_scan_devices_seen: s.last_scan_devices_seen, - last_scan_at: s.last_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; - s.last_scan_devices_seen = None; - s.last_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_record_scan() { - reset_for_test(); - record_scan(7); - let snapshot = get().unwrap(); - assert_eq!(snapshot.last_scan_devices_seen, Some(7)); - assert!(snapshot.last_scan_at.is_some()); - } - - #[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()); - assert!(snapshot.last_scan_devices_seen.is_none()); - assert!(snapshot.last_scan_at.is_none()); - } +pub fn get() -> Option { + STATUS.get().map(|m| m.lock().unwrap().snapshot()) } diff --git a/backend/src/scanners/ssdp/scanner.rs b/backend/src/scanners/ssdp/scanner.rs index 9cf8c3f..c89a8cc 100644 --- a/backend/src/scanners/ssdp/scanner.rs +++ b/backend/src/scanners/ssdp/scanner.rs @@ -1,17 +1,13 @@ use std::net::{IpAddr, Ipv4Addr}; use std::time::Duration; -use chrono::Local; -use log::{debug, error, info, warn}; +use log::{debug, info, warn}; use super::finder; use super::status; -use crate::data::mac_vendor_finder; -use crate::data::vendor_device_type_finder; -use crate::db; -use crate::events; use crate::model::device_events::DeviceEventScanner; -use crate::model::devices::Device; +use crate::scanners::common::enrichment::build_device; +use crate::scanners::common::pipeline; use crate::settings::get_settings; /// Passively listen for SSDP/UPnP NOTIFY announcements and feed discovered devices into the same @@ -76,52 +72,10 @@ async fn process_announcement( } }; - let mut vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string()); - // Privacy MACs are locally administered and have no real OUI, so the lookup above fails. - // Fall back to the vendor-specific service strings the device advertises (SSDP NT URNs - // typically won't match this lookup, but the call shape mirrors the mDNS scanner). - if vendor.is_empty() && crate::utils::network::is_locally_administered(&mac) { - vendor = crate::data::service_vendor_finder::find(&device_types); - } - let mut device = Device::new( - mac.clone(), - src_ip.to_string(), - vendor, - Local::now().to_utc(), - ); - device.device_type = vendor_device_type_finder::find(&device.vendor); - device.name = server_hint; - - match db::devices::read(mac.clone()) { - Some(recorded) => { - debug!("SSDP sighting of known device {mac}; updating"); - // Keep the previously stored name (likely a proper hostname from mDNS) rather than - // overwriting it with the SERVER header string. - if recorded.name.is_some() { - device.name = recorded.name.clone(); - } - if let Err(err) = db::devices::seen( - device.mac_address.clone(), - device.ipv4_address.clone(), - device.vendor.clone(), - device.device_type.clone(), - device.name.clone(), - ) { - error!("Failed to update SSDP device {mac}: {err}"); - return; - } - // Ignoring errors: do not stop the listener if notification delivery fails - events::trigger_existing_device(recorded, device, DeviceEventScanner::Ssdp).ok(); - } - None => { - debug!("New device {mac} discovered via SSDP; inserting"); - if let Err(err) = db::devices::insert(device.clone()) { - error!("Failed to insert SSDP device {mac}: {err}"); - return; - } - events::trigger_new_device(device, DeviceEventScanner::Ssdp).ok(); - } - } + // The advertised SSDP NT device-type URNs rarely match the vendor lookup, but the call shape + // mirrors the mDNS scanner so build_device can still deduce a vendor for privacy MACs. + let device = build_device(mac.clone(), src_ip.to_string(), &device_types, server_hint); + pipeline::record_sighting(device, DeviceEventScanner::Ssdp); status::record_discovery(&mac); } diff --git a/backend/src/scanners/ssdp/status.rs b/backend/src/scanners/ssdp/status.rs index ca10fb5..f4dc1f5 100644 --- a/backend/src/scanners/ssdp/status.rs +++ b/backend/src/scanners/ssdp/status.rs @@ -1,141 +1,26 @@ -use chrono::{DateTime, Duration, Utc}; use once_cell::sync::OnceCell; -use std::collections::HashMap; use std::sync::Mutex; -/// A device counts as "seen" only if its most recent sighting falls within this -/// rolling window. -const RECENT_WINDOW_SECONDS: i64 = 3600; +use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus}; -pub struct SsdpScannerStatus { - pub is_listening: bool, - pub listening_since: Option>, - /// Most recent sighting time per device MAC, used to count the distinct - /// devices seen within the last hour. - pub recent_sightings: HashMap>, - pub last_discovery_at: Option>, -} - -#[derive(Clone)] -pub struct SsdpScannerStatusSnapshot { - pub is_listening: bool, - pub listening_since: Option>, - /// Distinct devices seen within the last hour. - pub devices_last_hour: u64, - pub last_discovery_at: Option>, -} - -static STATUS: OnceCell> = OnceCell::new(); +static STATUS: OnceCell> = OnceCell::new(); pub fn init() { - STATUS - .set(Mutex::new(SsdpScannerStatus { - is_listening: false, - listening_since: None, - recent_sightings: HashMap::new(), - last_discovery_at: None, - })) - .ok(); + STATUS.set(Mutex::new(PassiveStatus::new())).ok(); } pub fn set_listening() { if let Some(m) = STATUS.get() { - let mut s = m.lock().unwrap(); - s.is_listening = true; - s.listening_since = Some(Utc::now()); + m.lock().unwrap().set_listening(); } } pub fn record_discovery(mac: &str) { if let Some(m) = STATUS.get() { - let now = Utc::now(); - let mut s = m.lock().unwrap(); - s.recent_sightings.insert(mac.to_string(), now); - s.last_discovery_at = Some(now); + m.lock().unwrap().record_discovery(mac); } } -pub fn get() -> Option { - STATUS.get().map(|m| { - let mut s = m.lock().unwrap(); - let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS); - s.recent_sightings.retain(|_, seen| *seen >= cutoff); - SsdpScannerStatusSnapshot { - is_listening: s.is_listening, - listening_since: s.listening_since, - devices_last_hour: s.recent_sightings.len() as u64, - last_discovery_at: s.last_discovery_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_listening = false; - s.listening_since = None; - s.recent_sightings.clear(); - s.last_discovery_at = None; - } else { - init(); - } - } - - #[test] - fn test_set_listening() { - reset_for_test(); - set_listening(); - let snapshot = get().unwrap(); - assert!(snapshot.is_listening); - assert!(snapshot.listening_since.is_some()); - } - - #[test] - fn test_same_mac_counts_once() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - record_discovery("aa:bb:cc:dd:ee:ff"); - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 1); - assert!(snapshot.last_discovery_at.is_some()); - } - - #[test] - fn test_distinct_macs_counted() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - record_discovery("11:22:33:44:55:66"); - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 2); - } - - #[test] - fn test_old_sighting_excluded() { - reset_for_test(); - record_discovery("aa:bb:cc:dd:ee:ff"); - // Backdate an entry beyond the window; it must not be counted. - if let Some(m) = STATUS.get() { - let mut s = m.lock().unwrap(); - s.recent_sightings.insert( - "11:22:33:44:55:66".to_string(), - Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60), - ); - } - let snapshot = get().unwrap(); - assert_eq!(snapshot.devices_last_hour, 1); - } - - #[test] - fn test_initial_state() { - reset_for_test(); - let snapshot = get().unwrap(); - assert!(!snapshot.is_listening); - assert!(snapshot.listening_since.is_none()); - assert_eq!(snapshot.devices_last_hour, 0); - assert!(snapshot.last_discovery_at.is_none()); - } +pub fn get() -> Option { + STATUS.get().map(|m| m.lock().unwrap().snapshot()) } diff --git a/backend/src/utils/network.rs b/backend/src/utils/network.rs index 75db332..e31bad6 100644 --- a/backend/src/utils/network.rs +++ b/backend/src/utils/network.rs @@ -155,6 +155,15 @@ pub fn is_locally_administered(mac: &str) -> bool { .is_some_and(|first_octet| first_octet & 0x02 != 0) } +/// Format raw MAC bytes as a lowercase colon-separated string (e.g. `0a:00:ff:10:20:30`). +pub fn format_mac(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} + #[cfg(test)] mod tests { use super::*; @@ -288,4 +297,12 @@ mod tests { assert_eq!(normalize_mac("Aa:Bb:Cc:Dd:Ee:Ff"), "aa:bb:cc:dd:ee:ff"); assert_eq!(normalize_mac(""), ""); } + + #[test] + fn test_format_mac_pads_and_lowercases() { + assert_eq!( + format_mac(&[0x0a, 0x00, 0xff, 0x10, 0x20, 0x30]), + "0a:00:ff:10:20:30" + ); + } } diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index 74e2de0..80da4f0 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -5,12 +5,10 @@ use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventTy use crate::model::devices::{Device, DeviceSummary}; use crate::model::notifications::{Notification, NotificationType}; use crate::settings::get_settings; -use crate::web_server::arp_scanner::ArpScannerStatusResponse; use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload}; -use crate::web_server::dhcp_scanner::DhcpScannerStatusResponse; -use crate::web_server::mdns_scanner::MdnsScannerStatusResponse; -use crate::web_server::snmp_scanner::SnmpScannerStatusResponse; -use crate::web_server::ssdp_scanner::SsdpScannerStatusResponse; +use crate::web_server::scanner_status::{ + ActiveScannerStatusResponse, PassiveScannerStatusResponse, +}; use axum::Json; use axum::extract::Request; use axum::http::StatusCode; @@ -33,6 +31,7 @@ pub mod devices; pub mod dhcp_scanner; pub mod mdns_scanner; pub mod notifications; +pub mod scanner_status; pub mod snmp_scanner; pub mod ssdp_scanner; pub mod utils; @@ -74,11 +73,8 @@ pub mod utils; DeviceEvent, DeviceEventType, DeviceEventScanner, - ArpScannerStatusResponse, - MdnsScannerStatusResponse, - SsdpScannerStatusResponse, - DhcpScannerStatusResponse, - SnmpScannerStatusResponse, + ActiveScannerStatusResponse, + PassiveScannerStatusResponse, )), modifiers(&SecurityAddon), tags( diff --git a/backend/src/web_server/arp_scanner.rs b/backend/src/web_server/arp_scanner.rs index ea5d956..d2c3e3b 100644 --- a/backend/src/web_server/arp_scanner.rs +++ b/backend/src/web_server/arp_scanner.rs @@ -1,65 +1,17 @@ use axum::{Json, http::StatusCode}; -use chrono::Utc; -use log::error; -use serde::Serialize; -use utoipa::ToSchema; -#[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, - /// Number of devices found by the last successful scan (None if none completed yet) - pub last_scan_devices_seen: Option, - /// Seconds since the last successful scan completed (None if none completed yet) - pub last_scan_seconds_ago: Option, -} +use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_response}; #[utoipa::path( get, path = "/api/arp_scanner/status", tag = "arp_scanner", responses( - (status = 200, description = "ARP scanner status", body = ArpScannerStatusResponse), + (status = 200, description = "ARP scanner status", body = ActiveScannerStatusResponse), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] -pub async fn status() -> Result, StatusCode> { - let snapshot = match crate::scanners::arp::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) - }; - - let last_scan_seconds_ago = snapshot - .last_scan_at - .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); - - Ok(Json(ArpScannerStatusResponse { - is_running: snapshot.is_running, - running_for_seconds, - next_run_in_seconds, - last_scan_devices_seen: snapshot.last_scan_devices_seen, - last_scan_seconds_ago, - })) +pub async fn status() -> Result, StatusCode> { + active_response(crate::scanners::arp::status::get()).map(Json) } diff --git a/backend/src/web_server/dhcp_scanner.rs b/backend/src/web_server/dhcp_scanner.rs index ccb9426..8b7071f 100644 --- a/backend/src/web_server/dhcp_scanner.rs +++ b/backend/src/web_server/dhcp_scanner.rs @@ -1,57 +1,17 @@ use axum::{Json, http::StatusCode}; -use chrono::Utc; -use log::error; -use serde::Serialize; -use utoipa::ToSchema; -#[derive(Serialize, ToSchema)] -pub struct DhcpScannerStatusResponse { - pub is_listening: bool, - /// Seconds the listener has been running (only set when is_listening is true) - pub listening_for_seconds: Option, - /// Distinct devices seen in the last hour - pub devices_seen: u64, - /// Seconds since the last device was seen (None if none seen yet) - pub last_device_seen_seconds_ago: Option, -} +use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response}; #[utoipa::path( get, path = "/api/dhcp_scanner/status", tag = "dhcp_scanner", responses( - (status = 200, description = "DHCP scanner status", body = DhcpScannerStatusResponse), + (status = 200, description = "DHCP scanner status", body = PassiveScannerStatusResponse), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] -pub async fn status() -> Result, StatusCode> { - let snapshot = match crate::scanners::dhcp::status::get() { - Some(s) => s, - None => { - error!("DHCP scanner status not initialized"); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - let now = Utc::now(); - - let listening_for_seconds = if snapshot.is_listening { - snapshot - .listening_since - .map(|t| (now - t).num_milliseconds() as f64 / 1000.0) - } else { - None - }; - - let last_device_seen_seconds_ago = snapshot - .last_discovery_at - .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); - - Ok(Json(DhcpScannerStatusResponse { - is_listening: snapshot.is_listening, - listening_for_seconds, - devices_seen: snapshot.devices_last_hour, - last_device_seen_seconds_ago, - })) +pub async fn status() -> Result, StatusCode> { + passive_response(crate::scanners::dhcp::status::get()).map(Json) } diff --git a/backend/src/web_server/mdns_scanner.rs b/backend/src/web_server/mdns_scanner.rs index 0de549d..bfcf62f 100644 --- a/backend/src/web_server/mdns_scanner.rs +++ b/backend/src/web_server/mdns_scanner.rs @@ -1,57 +1,17 @@ use axum::{Json, http::StatusCode}; -use chrono::Utc; -use log::error; -use serde::Serialize; -use utoipa::ToSchema; -#[derive(Serialize, ToSchema)] -pub struct MdnsScannerStatusResponse { - pub is_listening: bool, - /// Seconds the listener has been running (only set when is_listening is true) - pub listening_for_seconds: Option, - /// Distinct devices seen in the last hour - pub devices_seen: u64, - /// Seconds since the last device was seen (None if none seen yet) - pub last_device_seen_seconds_ago: Option, -} +use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response}; #[utoipa::path( get, path = "/api/mdns_scanner/status", tag = "mdns_scanner", responses( - (status = 200, description = "mDNS scanner status", body = MdnsScannerStatusResponse), + (status = 200, description = "mDNS scanner status", body = PassiveScannerStatusResponse), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] -pub async fn status() -> Result, StatusCode> { - let snapshot = match crate::scanners::mdns::status::get() { - Some(s) => s, - None => { - error!("mDNS scanner status not initialized"); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - let now = Utc::now(); - - let listening_for_seconds = if snapshot.is_listening { - snapshot - .listening_since - .map(|t| (now - t).num_milliseconds() as f64 / 1000.0) - } else { - None - }; - - let last_device_seen_seconds_ago = snapshot - .last_discovery_at - .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); - - Ok(Json(MdnsScannerStatusResponse { - is_listening: snapshot.is_listening, - listening_for_seconds, - devices_seen: snapshot.devices_last_hour, - last_device_seen_seconds_ago, - })) +pub async fn status() -> Result, StatusCode> { + passive_response(crate::scanners::mdns::status::get()).map(Json) } diff --git a/backend/src/web_server/scanner_status.rs b/backend/src/web_server/scanner_status.rs new file mode 100644 index 0000000..8720c80 --- /dev/null +++ b/backend/src/web_server/scanner_status.rs @@ -0,0 +1,89 @@ +use axum::http::StatusCode; +use chrono::Utc; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::scanners::common::active_status::ActiveSnapshot; +use crate::scanners::common::passive_status::PassiveSnapshot; + +/// API status of an active (polling) scanner — ARP and SNMP. +#[derive(Serialize, ToSchema)] +pub struct ActiveScannerStatusResponse { + 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, + /// Number of devices found by the last successful scan (None if none completed yet) + pub last_scan_devices_seen: Option, + /// Seconds since the last successful scan completed (None if none completed yet) + pub last_scan_seconds_ago: Option, +} + +/// API status of a passive (listening) scanner — mDNS, SSDP and DHCP. +#[derive(Serialize, ToSchema)] +pub struct PassiveScannerStatusResponse { + pub is_listening: bool, + /// Seconds the listener has been running (only set when is_listening is true) + pub listening_for_seconds: Option, + /// Distinct devices seen in the last hour + pub devices_seen: u64, + /// Seconds since the last device was seen (None if none seen yet) + pub last_device_seen_seconds_ago: Option, +} + +/// Non-negative seconds from `earlier` to `later` (0 if `later` is before `earlier`). +fn seconds_between(earlier: chrono::DateTime, later: chrono::DateTime) -> f64 { + ((later - earlier).num_milliseconds() as f64 / 1000.0).max(0.0) +} + +/// Build the response for an active scanner. `None` (status tracking never initialized) maps to a +/// 500. +pub fn active_response( + snapshot: Option, +) -> Result { + let snapshot = snapshot.ok_or(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| seconds_between(now, t)); + (None, next_run_in) + }; + + Ok(ActiveScannerStatusResponse { + is_running: snapshot.is_running, + running_for_seconds, + next_run_in_seconds, + last_scan_devices_seen: snapshot.last_scan_devices_seen, + last_scan_seconds_ago: snapshot.last_scan_at.map(|t| seconds_between(t, now)), + }) +} + +/// Build the response for a passive scanner. `None` (status tracking never initialized) maps to a +/// 500. +pub fn passive_response( + snapshot: Option, +) -> Result { + let snapshot = snapshot.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let now = Utc::now(); + + let listening_for_seconds = if snapshot.is_listening { + snapshot + .listening_since + .map(|t| (now - t).num_milliseconds() as f64 / 1000.0) + } else { + None + }; + + Ok(PassiveScannerStatusResponse { + is_listening: snapshot.is_listening, + listening_for_seconds, + devices_seen: snapshot.devices_last_hour, + last_device_seen_seconds_ago: snapshot.last_discovery_at.map(|t| seconds_between(t, now)), + }) +} diff --git a/backend/src/web_server/snmp_scanner.rs b/backend/src/web_server/snmp_scanner.rs index 45c51ec..90fd5bd 100644 --- a/backend/src/web_server/snmp_scanner.rs +++ b/backend/src/web_server/snmp_scanner.rs @@ -1,65 +1,17 @@ use axum::{Json, http::StatusCode}; -use chrono::Utc; -use log::error; -use serde::Serialize; -use utoipa::ToSchema; -#[derive(Serialize, ToSchema)] -pub struct SnmpScannerStatusResponse { - pub is_running: bool, - /// Seconds the current poll has been running (only set when is_running is true) - pub running_for_seconds: Option, - /// Seconds until the next poll starts (only set when is_running is false; clamped to 0) - pub next_run_in_seconds: Option, - /// Number of devices found by the last successful scan (None if none completed yet) - pub last_scan_devices_seen: Option, - /// Seconds since the last successful scan completed (None if none completed yet) - pub last_scan_seconds_ago: Option, -} +use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_response}; #[utoipa::path( get, path = "/api/snmp_scanner/status", tag = "snmp_scanner", responses( - (status = 200, description = "SNMP scanner status", body = SnmpScannerStatusResponse), + (status = 200, description = "SNMP scanner status", body = ActiveScannerStatusResponse), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] -pub async fn status() -> Result, StatusCode> { - let snapshot = match crate::scanners::snmp::status::get() { - Some(s) => s, - None => { - error!("SNMP 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) - }; - - let last_scan_seconds_ago = snapshot - .last_scan_at - .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); - - Ok(Json(SnmpScannerStatusResponse { - is_running: snapshot.is_running, - running_for_seconds, - next_run_in_seconds, - last_scan_devices_seen: snapshot.last_scan_devices_seen, - last_scan_seconds_ago, - })) +pub async fn status() -> Result, StatusCode> { + active_response(crate::scanners::snmp::status::get()).map(Json) } diff --git a/backend/src/web_server/ssdp_scanner.rs b/backend/src/web_server/ssdp_scanner.rs index 8eafd95..9150521 100644 --- a/backend/src/web_server/ssdp_scanner.rs +++ b/backend/src/web_server/ssdp_scanner.rs @@ -1,57 +1,17 @@ use axum::{Json, http::StatusCode}; -use chrono::Utc; -use log::error; -use serde::Serialize; -use utoipa::ToSchema; -#[derive(Serialize, ToSchema)] -pub struct SsdpScannerStatusResponse { - pub is_listening: bool, - /// Seconds the listener has been running (only set when is_listening is true) - pub listening_for_seconds: Option, - /// Distinct devices seen in the last hour - pub devices_seen: u64, - /// Seconds since the last device was seen (None if none seen yet) - pub last_device_seen_seconds_ago: Option, -} +use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response}; #[utoipa::path( get, path = "/api/ssdp_scanner/status", tag = "ssdp_scanner", responses( - (status = 200, description = "SSDP scanner status", body = SsdpScannerStatusResponse), + (status = 200, description = "SSDP scanner status", body = PassiveScannerStatusResponse), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] -pub async fn status() -> Result, StatusCode> { - let snapshot = match crate::scanners::ssdp::status::get() { - Some(s) => s, - None => { - error!("SSDP scanner status not initialized"); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - let now = Utc::now(); - - let listening_for_seconds = if snapshot.is_listening { - snapshot - .listening_since - .map(|t| (now - t).num_milliseconds() as f64 / 1000.0) - } else { - None - }; - - let last_device_seen_seconds_ago = snapshot - .last_discovery_at - .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); - - Ok(Json(SsdpScannerStatusResponse { - is_listening: snapshot.is_listening, - listening_for_seconds, - devices_seen: snapshot.devices_last_hour, - last_device_seen_seconds_ago, - })) +pub async fn status() -> Result, StatusCode> { + passive_response(crate::scanners::ssdp::status::get()).map(Json) }