mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
72dbbe5e9a
commit
5f7a1287a1
@@ -1,4 +1,5 @@
|
||||
pub mod arp;
|
||||
pub mod common;
|
||||
pub mod dhcp;
|
||||
pub mod error;
|
||||
pub mod mdns;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
// 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);
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
pub next_scan_at: Option<DateTime<Utc>>,
|
||||
pub last_scan_devices_seen: Option<u64>,
|
||||
pub last_scan_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ArpScannerStatusSnapshot {
|
||||
pub is_running: bool,
|
||||
pub scan_started_at: Option<DateTime<Utc>>,
|
||||
pub next_scan_at: Option<DateTime<Utc>>,
|
||||
pub last_scan_devices_seen: Option<u64>,
|
||||
pub last_scan_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
static STATUS: OnceCell<Mutex<ArpScannerStatus>> = OnceCell::new();
|
||||
static STATUS: OnceCell<Mutex<ActiveStatus>> = 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<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);
|
||||
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<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,
|
||||
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<ActiveSnapshot> {
|
||||
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,104 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Status state for the active (polling) scanners — ARP and SNMP. Each scanner owns its own
|
||||
/// `OnceCell<Mutex<ActiveStatus>>` and delegates to these methods (see e.g.
|
||||
/// [`crate::scanners::arp::status`]).
|
||||
#[derive(Default)]
|
||||
pub struct ActiveStatus {
|
||||
is_running: bool,
|
||||
scan_started_at: Option<DateTime<Utc>>,
|
||||
next_scan_at: Option<DateTime<Utc>>,
|
||||
last_scan_devices_seen: Option<u64>,
|
||||
last_scan_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 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<DateTime<Utc>>,
|
||||
pub next_scan_at: Option<DateTime<Utc>>,
|
||||
pub last_scan_devices_seen: Option<u64>,
|
||||
pub last_scan_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
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<Utc>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
) -> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Mutex<PassiveStatus>>` and delegates to these methods (see e.g.
|
||||
/// [`crate::scanners::mdns::status`]).
|
||||
#[derive(Default)]
|
||||
pub struct PassiveStatus {
|
||||
is_listening: bool,
|
||||
listening_since: Option<DateTime<Utc>>,
|
||||
/// Most recent sighting time per device MAC, used to count the distinct devices seen within
|
||||
/// the last hour.
|
||||
recent_sightings: HashMap<String, DateTime<Utc>>,
|
||||
last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 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<DateTime<Utc>>,
|
||||
/// Distinct devices seen within the last hour.
|
||||
pub devices_last_hour: u64,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<DhcpDiscovery> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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::<Vec<_>>()
|
||||
.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());
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
/// Most recent sighting time per device MAC, used to count the distinct
|
||||
/// devices seen within the last hour.
|
||||
pub recent_sightings: HashMap<String, DateTime<Utc>>,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DhcpScannerStatusSnapshot {
|
||||
pub is_listening: bool,
|
||||
pub listening_since: Option<DateTime<Utc>>,
|
||||
/// Distinct devices seen within the last hour.
|
||||
pub devices_last_hour: u64,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
static STATUS: OnceCell<Mutex<DhcpScannerStatus>> = OnceCell::new();
|
||||
static STATUS: OnceCell<Mutex<PassiveStatus>> = 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<DhcpScannerStatusSnapshot> {
|
||||
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<PassiveSnapshot> {
|
||||
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
/// Most recent sighting time per device MAC, used to count the distinct
|
||||
/// devices seen within the last hour.
|
||||
pub recent_sightings: HashMap<String, DateTime<Utc>>,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MdnsScannerStatusSnapshot {
|
||||
pub is_listening: bool,
|
||||
pub listening_since: Option<DateTime<Utc>>,
|
||||
/// Distinct devices seen within the last hour.
|
||||
pub devices_last_hour: u64,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
static STATUS: OnceCell<Mutex<MdnsScannerStatus>> = OnceCell::new();
|
||||
static STATUS: OnceCell<Mutex<PassiveStatus>> = 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<MdnsScannerStatusSnapshot> {
|
||||
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<PassiveSnapshot> {
|
||||
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||
}
|
||||
|
||||
@@ -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<Vec<Device>, Box<dyn std::erro
|
||||
|
||||
let devices = pairs
|
||||
.into_iter()
|
||||
.map(|(mac, ip)| {
|
||||
let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
|
||||
let mut device = Device::new(mac, ip.to_string(), vendor, Local::now().to_utc());
|
||||
device.device_type = vendor_device_type_finder::find(&device.vendor);
|
||||
device
|
||||
})
|
||||
.map(|(mac, ip)| build_device(mac, ip.to_string(), &[], None))
|
||||
.collect();
|
||||
|
||||
Ok(devices)
|
||||
@@ -103,15 +97,6 @@ where
|
||||
pairs
|
||||
}
|
||||
|
||||
/// 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::<Vec<_>>()
|
||||
.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)
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
pub next_scan_at: Option<DateTime<Utc>>,
|
||||
pub last_scan_devices_seen: Option<u64>,
|
||||
pub last_scan_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SnmpScannerStatusSnapshot {
|
||||
pub is_running: bool,
|
||||
pub scan_started_at: Option<DateTime<Utc>>,
|
||||
pub next_scan_at: Option<DateTime<Utc>>,
|
||||
pub last_scan_devices_seen: Option<u64>,
|
||||
pub last_scan_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
static STATUS: OnceCell<Mutex<SnmpScannerStatus>> = OnceCell::new();
|
||||
static STATUS: OnceCell<Mutex<ActiveStatus>> = 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<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);
|
||||
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<SnmpScannerStatusSnapshot> {
|
||||
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<ActiveSnapshot> {
|
||||
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
/// Most recent sighting time per device MAC, used to count the distinct
|
||||
/// devices seen within the last hour.
|
||||
pub recent_sightings: HashMap<String, DateTime<Utc>>,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SsdpScannerStatusSnapshot {
|
||||
pub is_listening: bool,
|
||||
pub listening_since: Option<DateTime<Utc>>,
|
||||
/// Distinct devices seen within the last hour.
|
||||
pub devices_last_hour: u64,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
static STATUS: OnceCell<Mutex<SsdpScannerStatus>> = OnceCell::new();
|
||||
static STATUS: OnceCell<Mutex<PassiveStatus>> = 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<SsdpScannerStatusSnapshot> {
|
||||
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<PassiveSnapshot> {
|
||||
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<f64>,
|
||||
/// Seconds until the next scan starts (only set when is_running is false; clamped to 0)
|
||||
pub next_run_in_seconds: Option<f64>,
|
||||
/// Number of devices found by the last successful scan (None if none completed yet)
|
||||
pub last_scan_devices_seen: Option<u64>,
|
||||
/// Seconds since the last successful scan completed (None if none completed yet)
|
||||
pub last_scan_seconds_ago: Option<f64>,
|
||||
}
|
||||
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<Json<ArpScannerStatusResponse>, 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<Json<ActiveScannerStatusResponse>, StatusCode> {
|
||||
active_response(crate::scanners::arp::status::get()).map(Json)
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
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<Json<DhcpScannerStatusResponse>, 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<Json<PassiveScannerStatusResponse>, StatusCode> {
|
||||
passive_response(crate::scanners::dhcp::status::get()).map(Json)
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
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<Json<MdnsScannerStatusResponse>, 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<Json<PassiveScannerStatusResponse>, StatusCode> {
|
||||
passive_response(crate::scanners::mdns::status::get()).map(Json)
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
/// Seconds until the next scan starts (only set when is_running is false; clamped to 0)
|
||||
pub next_run_in_seconds: Option<f64>,
|
||||
/// Number of devices found by the last successful scan (None if none completed yet)
|
||||
pub last_scan_devices_seen: Option<u64>,
|
||||
/// Seconds since the last successful scan completed (None if none completed yet)
|
||||
pub last_scan_seconds_ago: Option<f64>,
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
/// Non-negative seconds from `earlier` to `later` (0 if `later` is before `earlier`).
|
||||
fn seconds_between(earlier: chrono::DateTime<Utc>, later: chrono::DateTime<Utc>) -> 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<ActiveSnapshot>,
|
||||
) -> Result<ActiveScannerStatusResponse, StatusCode> {
|
||||
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<PassiveSnapshot>,
|
||||
) -> Result<PassiveScannerStatusResponse, StatusCode> {
|
||||
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)),
|
||||
})
|
||||
}
|
||||
@@ -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<f64>,
|
||||
/// Seconds until the next poll starts (only set when is_running is false; clamped to 0)
|
||||
pub next_run_in_seconds: Option<f64>,
|
||||
/// Number of devices found by the last successful scan (None if none completed yet)
|
||||
pub last_scan_devices_seen: Option<u64>,
|
||||
/// Seconds since the last successful scan completed (None if none completed yet)
|
||||
pub last_scan_seconds_ago: Option<f64>,
|
||||
}
|
||||
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<Json<SnmpScannerStatusResponse>, 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<Json<ActiveScannerStatusResponse>, StatusCode> {
|
||||
active_response(crate::scanners::snmp::status::get()).map(Json)
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
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<Json<SsdpScannerStatusResponse>, 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<Json<PassiveScannerStatusResponse>, StatusCode> {
|
||||
passive_response(crate::scanners::ssdp::status::get()).map(Json)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user