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
@@ -11,7 +11,7 @@
|
|||||||
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
|
- [ ] 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] 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
|
- [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
|
- [ ] 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
|
- [x] Review the recommended timings for the ARP scanner (change defaults) — SNMP defaults set to 10m/5s
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod arp;
|
pub mod arp;
|
||||||
|
pub mod common;
|
||||||
pub mod dhcp;
|
pub mod dhcp;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod mdns;
|
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 crate::model::devices::Device;
|
||||||
use chrono::Local;
|
use crate::scanners::common::enrichment::build_device;
|
||||||
use duration_string::DurationString;
|
use duration_string::DurationString;
|
||||||
use log::{debug, info, trace};
|
use log::{debug, info, trace};
|
||||||
use pnet::datalink::{DataLinkReceiver, DataLinkSender, NetworkInterface};
|
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() {
|
if arp_packet.get_target_proto_addr() == ipv4_net.ip() {
|
||||||
let packet_mac_address = arp_packet.get_sender_hw_addr().to_string();
|
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_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!(
|
// ARP carries no hostname or service hints, so vendor comes from the OUI only.
|
||||||
"Found online device - IP addr={} - MAC addr={} - vendor={}",
|
let device = build_device(packet_mac_address, packet_ip_address, &[], None);
|
||||||
packet_ip_address, packet_mac_address, packet_vendor
|
debug!("Found online device {device}");
|
||||||
);
|
|
||||||
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);
|
|
||||||
devices.push(device);
|
devices.push(device);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use super::finder;
|
use super::finder;
|
||||||
use super::status;
|
use super::status;
|
||||||
use crate::db;
|
|
||||||
use crate::events;
|
|
||||||
use crate::model::device_events::DeviceEventScanner;
|
use crate::model::device_events::DeviceEventScanner;
|
||||||
|
use crate::scanners::common::pipeline;
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use log::{debug, info};
|
use log::{debug, info};
|
||||||
@@ -27,42 +26,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Process found devices
|
// Process found devices
|
||||||
for device in devices.iter() {
|
for device in devices.iter() {
|
||||||
debug!("Online device found {}", device);
|
debug!("Online device found {}", device);
|
||||||
|
pipeline::record_sighting(device.clone(), DeviceEventScanner::Arp);
|
||||||
// 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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let wait = Duration::from(get_settings().arp_scanner.wait_between_scans);
|
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 once_cell::sync::OnceCell;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
pub struct ArpScannerStatus {
|
use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus};
|
||||||
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>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
static STATUS: OnceCell<Mutex<ActiveStatus>> = OnceCell::new();
|
||||||
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();
|
|
||||||
|
|
||||||
pub fn init() {
|
pub fn init() {
|
||||||
STATUS
|
STATUS.set(Mutex::new(ActiveStatus::new())).ok();
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_running() {
|
pub fn set_running() {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().set_running();
|
||||||
s.is_running = true;
|
|
||||||
s.scan_started_at = Some(Utc::now());
|
|
||||||
s.next_scan_at = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_waiting(next_scan_at: DateTime<Utc>) {
|
pub fn set_waiting(next_scan_at: DateTime<Utc>) {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().set_waiting(next_scan_at);
|
||||||
s.is_running = false;
|
|
||||||
s.scan_started_at = None;
|
|
||||||
s.next_scan_at = Some(next_scan_at);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_scan(devices_seen: u64) {
|
pub fn record_scan(devices_seen: u64) {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().record_scan(devices_seen);
|
||||||
s.last_scan_devices_seen = Some(devices_seen);
|
|
||||||
s.last_scan_at = Some(Utc::now());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get() -> Option<ArpScannerStatusSnapshot> {
|
pub fn get() -> Option<ActiveSnapshot> {
|
||||||
STATUS.get().map(|m| {
|
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 socket2::{Domain, Protocol, Socket, Type};
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
use crate::utils::network::format_mac;
|
||||||
|
|
||||||
const DHCP_SERVER_PORT: u16 = 67;
|
const DHCP_SERVER_PORT: u16 = 67;
|
||||||
|
|
||||||
// DHCP/BOOTP fixed-header field offsets (RFC 2131).
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -184,7 +177,8 @@ mod tests {
|
|||||||
let ci = ciaddr.octets();
|
let ci = ciaddr.octets();
|
||||||
buf[CIADDR_OFFSET..CIADDR_OFFSET + 4].copy_from_slice(&ci);
|
buf[CIADDR_OFFSET..CIADDR_OFFSET + 4].copy_from_slice(&ci);
|
||||||
// chaddr: aa:bb:cc:dd:ee:ff
|
// 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[MAGIC_COOKIE_OFFSET..OPTIONS_OFFSET].copy_from_slice(&MAGIC_COOKIE);
|
||||||
buf.extend_from_slice(options);
|
buf.extend_from_slice(options);
|
||||||
buf.push(OPTION_END);
|
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_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]);
|
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");
|
let parsed = parse_packet(&buf).expect("discover packet");
|
||||||
assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff");
|
assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff");
|
||||||
assert_eq!(parsed.hostname.as_deref(), Some("laptop"));
|
assert_eq!(parsed.hostname.as_deref(), Some("laptop"));
|
||||||
@@ -229,7 +229,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_parse_discover_without_ip_yields_none_hint() {
|
fn test_parse_discover_without_ip_yields_none_hint() {
|
||||||
let options = message_type_option(DHCP_DISCOVER);
|
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");
|
let parsed = parse_packet(&buf).expect("discover packet");
|
||||||
assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff");
|
assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff");
|
||||||
assert_eq!(parsed.hostname, None);
|
assert_eq!(parsed.hostname, None);
|
||||||
@@ -240,7 +246,13 @@ mod tests {
|
|||||||
fn test_parse_server_reply_returns_none() {
|
fn test_parse_server_reply_returns_none() {
|
||||||
// op = 2 (BOOTREPLY) — a server message, ignored.
|
// op = 2 (BOOTREPLY) — a server message, ignored.
|
||||||
let options = message_type_option(DHCP_DISCOVER);
|
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());
|
assert!(parse_packet(&buf).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +260,13 @@ mod tests {
|
|||||||
fn test_parse_other_message_type_returns_none() {
|
fn test_parse_other_message_type_returns_none() {
|
||||||
// message type 5 = ACK (server->client), not a client request.
|
// message type 5 = ACK (server->client), not a client request.
|
||||||
let options = message_type_option(5);
|
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());
|
assert!(parse_packet(&buf).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,14 +274,26 @@ mod tests {
|
|||||||
fn test_parse_missing_message_type_returns_none() {
|
fn test_parse_missing_message_type_returns_none() {
|
||||||
// No option 53 present at all.
|
// No option 53 present at all.
|
||||||
let options = vec![OPTION_HOSTNAME, 4, b'h', b'o', b's', b't'];
|
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());
|
assert!(parse_packet(&buf).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_bad_magic_cookie_returns_none() {
|
fn test_parse_bad_magic_cookie_returns_none() {
|
||||||
let options = message_type_option(DHCP_DISCOVER);
|
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;
|
buf[MAGIC_COOKIE_OFFSET] = 0;
|
||||||
assert!(parse_packet(&buf).is_none());
|
assert!(parse_packet(&buf).is_none());
|
||||||
}
|
}
|
||||||
@@ -272,10 +302,22 @@ mod tests {
|
|||||||
fn test_parse_non_ethernet_returns_none() {
|
fn test_parse_non_ethernet_returns_none() {
|
||||||
let options = message_type_option(DHCP_DISCOVER);
|
let options = message_type_option(DHCP_DISCOVER);
|
||||||
// htype != 1
|
// 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());
|
assert!(parse_packet(&buf).is_none());
|
||||||
// hlen != 6
|
// 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());
|
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
|
// Option claims 4 bytes of payload but the buffer ends early. Must not panic and
|
||||||
// must not yield a discovery (message type never read).
|
// must not yield a discovery (message type never read).
|
||||||
let options = vec![OPTION_REQUESTED_IP, 4, 192, 168];
|
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.
|
// Drop the trailing END byte appended by build_packet to keep the option truncated.
|
||||||
let truncated = &buf[..buf.len() - 1];
|
let truncated = &buf[..buf.len() - 1];
|
||||||
assert!(parse_packet(truncated).is_none());
|
assert!(parse_packet(truncated).is_none());
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
use chrono::Local;
|
use log::{info, warn};
|
||||||
use log::{debug, error, info, warn};
|
|
||||||
|
|
||||||
use super::finder;
|
use super::finder;
|
||||||
use super::status;
|
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::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;
|
use crate::settings::get_settings;
|
||||||
|
|
||||||
/// Passively snoop DHCP DISCOVER/REQUEST broadcasts and feed discovered devices into the
|
/// 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
|
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;
|
let mac = discovery.mac;
|
||||||
|
|
||||||
// Unlike mDNS/SSDP, the MAC is carried in the packet (chaddr), so no ARP probe is
|
// Unlike mDNS/SSDP, the MAC is carried in the packet (chaddr), so no ARP probe is needed.
|
||||||
// needed. Privacy/locally-administered MACs have no real OUI, so the lookup returns
|
// DHCP advertises no service list, so there is no vendor fallback (pass no service hints); a
|
||||||
// an empty vendor; there is no DHCP equivalent of a service list to fall back on, so
|
// privacy MAC therefore yields an empty vendor, which the DB layer preserves. A DISCOVER
|
||||||
// it is left empty (the DB layer preserves any previously known vendor).
|
// carries no assigned IP, so the address may be empty here — the pipeline keeps any known IP.
|
||||||
let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
|
let ipv4 = discovery
|
||||||
|
.ip_hint
|
||||||
let ip = discovery.ip_hint.map(|ip| ip.to_string());
|
.map(|ip| ip.to_string())
|
||||||
let mut device = Device::new(
|
.unwrap_or_default();
|
||||||
mac.clone(),
|
let device = build_device(mac.clone(), ipv4, &[], discovery.hostname);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
pipeline::record_sighting(device, DeviceEventScanner::Dhcp);
|
||||||
status::record_discovery(&mac);
|
status::record_discovery(&mac);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,26 @@
|
|||||||
use chrono::{DateTime, Duration, Utc};
|
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// A device counts as "seen" only if its most recent sighting falls within this
|
use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus};
|
||||||
/// rolling window.
|
|
||||||
const RECENT_WINDOW_SECONDS: i64 = 3600;
|
|
||||||
|
|
||||||
pub struct DhcpScannerStatus {
|
static STATUS: OnceCell<Mutex<PassiveStatus>> = OnceCell::new();
|
||||||
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();
|
|
||||||
|
|
||||||
pub fn init() {
|
pub fn init() {
|
||||||
STATUS
|
STATUS.set(Mutex::new(PassiveStatus::new())).ok();
|
||||||
.set(Mutex::new(DhcpScannerStatus {
|
|
||||||
is_listening: false,
|
|
||||||
listening_since: None,
|
|
||||||
recent_sightings: HashMap::new(),
|
|
||||||
last_discovery_at: None,
|
|
||||||
}))
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_listening() {
|
pub fn set_listening() {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().set_listening();
|
||||||
s.is_listening = true;
|
|
||||||
s.listening_since = Some(Utc::now());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_discovery(mac: &str) {
|
pub fn record_discovery(mac: &str) {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let now = Utc::now();
|
m.lock().unwrap().record_discovery(mac);
|
||||||
let mut s = m.lock().unwrap();
|
|
||||||
s.recent_sightings.insert(mac.to_string(), now);
|
|
||||||
s.last_discovery_at = Some(now);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get() -> Option<DhcpScannerStatusSnapshot> {
|
pub fn get() -> Option<PassiveSnapshot> {
|
||||||
STATUS.get().map(|m| {
|
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Local;
|
use log::{debug, info, warn};
|
||||||
use log::{debug, error, info, warn};
|
|
||||||
|
|
||||||
use super::finder;
|
use super::finder;
|
||||||
use super::status;
|
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::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;
|
use crate::settings::get_settings;
|
||||||
|
|
||||||
/// Passively listen for mDNS/Bonjour announcements and feed discovered devices into the same
|
/// 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());
|
// The advertised mDNS service types let build_device deduce a vendor for privacy MACs whose
|
||||||
// Privacy MACs are locally administered and have no real OUI, so the lookup above fails.
|
// OUI lookup fails.
|
||||||
// Fall back to the vendor-specific mDNS services the device advertises.
|
let device = build_device(
|
||||||
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(
|
|
||||||
mac.clone(),
|
mac.clone(),
|
||||||
src_ip.to_string(),
|
src_ip.to_string(),
|
||||||
vendor,
|
&service_types,
|
||||||
Local::now().to_utc(),
|
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);
|
status::record_discovery(&mac);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,26 @@
|
|||||||
use chrono::{DateTime, Duration, Utc};
|
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// A device counts as "seen" only if its most recent sighting falls within this
|
use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus};
|
||||||
/// rolling window.
|
|
||||||
const RECENT_WINDOW_SECONDS: i64 = 3600;
|
|
||||||
|
|
||||||
pub struct MdnsScannerStatus {
|
static STATUS: OnceCell<Mutex<PassiveStatus>> = OnceCell::new();
|
||||||
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();
|
|
||||||
|
|
||||||
pub fn init() {
|
pub fn init() {
|
||||||
STATUS
|
STATUS.set(Mutex::new(PassiveStatus::new())).ok();
|
||||||
.set(Mutex::new(MdnsScannerStatus {
|
|
||||||
is_listening: false,
|
|
||||||
listening_since: None,
|
|
||||||
recent_sightings: HashMap::new(),
|
|
||||||
last_discovery_at: None,
|
|
||||||
}))
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_listening() {
|
pub fn set_listening() {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().set_listening();
|
||||||
s.is_listening = true;
|
|
||||||
s.listening_since = Some(Utc::now());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_discovery(mac: &str) {
|
pub fn record_discovery(mac: &str) {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let now = Utc::now();
|
m.lock().unwrap().record_discovery(mac);
|
||||||
let mut s = m.lock().unwrap();
|
|
||||||
s.recent_sightings.insert(mac.to_string(), now);
|
|
||||||
s.last_discovery_at = Some(now);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get() -> Option<MdnsScannerStatusSnapshot> {
|
pub fn get() -> Option<PassiveSnapshot> {
|
||||||
STATUS.get().map(|m| {
|
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
use std::net::{Ipv4Addr, SocketAddr};
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
|
||||||
use chrono::Local;
|
|
||||||
use csnmp::{ObjectIdentifier, ObjectValue, Snmp2cClient};
|
use csnmp::{ObjectIdentifier, ObjectValue, Snmp2cClient};
|
||||||
use log::{debug, info, warn};
|
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::model::devices::Device;
|
||||||
|
use crate::scanners::common::enrichment::build_device;
|
||||||
use crate::settings::SnmpScanner;
|
use crate::settings::SnmpScanner;
|
||||||
|
use crate::utils::network::format_mac;
|
||||||
|
|
||||||
// ipNetToMediaPhysAddress (RFC 1213 MIB-II): maps interface + IPv4 address to a MAC. Walking
|
// 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
|
// 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
|
let devices = pairs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(mac, ip)| {
|
.map(|(mac, ip)| build_device(mac, ip.to_string(), &[], None))
|
||||||
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
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(devices)
|
Ok(devices)
|
||||||
@@ -103,15 +97,6 @@ where
|
|||||||
pairs
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -141,7 +126,10 @@ mod tests {
|
|||||||
let pairs = parse_arp_table(rows, &base());
|
let pairs = parse_arp_table(rows, &base());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pairs,
|
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());
|
let pairs = parse_arp_table(rows, &base());
|
||||||
assert_eq!(pairs.len(), 2);
|
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(&(
|
||||||
assert!(pairs.contains(&("66:77:88:99:aa:bb".to_string(), Ipv4Addr::new(192, 168, 1, 3))));
|
"00:11:22:33:44:55".to_string(),
|
||||||
}
|
Ipv4Addr::new(192, 168, 1, 2)
|
||||||
|
)));
|
||||||
#[test]
|
assert!(pairs.contains(&(
|
||||||
fn format_mac_pads_and_lowercases() {
|
"66:77:88:99:aa:bb".to_string(),
|
||||||
assert_eq!(format_mac(&[0x0a, 0x00, 0xff, 0x10, 0x20, 0x30]), "0a:00:ff:10:20:30");
|
Ipv4Addr::new(192, 168, 1, 3)
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use super::finder;
|
use super::finder;
|
||||||
use super::status;
|
use super::status;
|
||||||
use crate::db;
|
|
||||||
use crate::events;
|
|
||||||
use crate::model::device_events::DeviceEventScanner;
|
use crate::model::device_events::DeviceEventScanner;
|
||||||
|
use crate::scanners::common::pipeline;
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use log::{debug, error, info};
|
use log::{error, info};
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
/// Periodically poll an SNMP agent (typically the gateway) for its ARP/neighbour cache and feed
|
/// 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());
|
info!("SNMP poll found {} devices in the ARP cache", devices.len());
|
||||||
status::record_scan(devices.len() as u64);
|
status::record_scan(devices.len() as u64);
|
||||||
for device in devices.iter() {
|
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),
|
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;
|
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 once_cell::sync::OnceCell;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
pub struct SnmpScannerStatus {
|
use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus};
|
||||||
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>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
static STATUS: OnceCell<Mutex<ActiveStatus>> = OnceCell::new();
|
||||||
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();
|
|
||||||
|
|
||||||
pub fn init() {
|
pub fn init() {
|
||||||
STATUS
|
STATUS.set(Mutex::new(ActiveStatus::new())).ok();
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_running() {
|
pub fn set_running() {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().set_running();
|
||||||
s.is_running = true;
|
|
||||||
s.scan_started_at = Some(Utc::now());
|
|
||||||
s.next_scan_at = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_waiting(next_scan_at: DateTime<Utc>) {
|
pub fn set_waiting(next_scan_at: DateTime<Utc>) {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().set_waiting(next_scan_at);
|
||||||
s.is_running = false;
|
|
||||||
s.scan_started_at = None;
|
|
||||||
s.next_scan_at = Some(next_scan_at);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_scan(devices_seen: u64) {
|
pub fn record_scan(devices_seen: u64) {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().record_scan(devices_seen);
|
||||||
s.last_scan_devices_seen = Some(devices_seen);
|
|
||||||
s.last_scan_at = Some(Utc::now());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get() -> Option<SnmpScannerStatusSnapshot> {
|
pub fn get() -> Option<ActiveSnapshot> {
|
||||||
STATUS.get().map(|m| {
|
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Local;
|
use log::{debug, info, warn};
|
||||||
use log::{debug, error, info, warn};
|
|
||||||
|
|
||||||
use super::finder;
|
use super::finder;
|
||||||
use super::status;
|
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::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;
|
use crate::settings::get_settings;
|
||||||
|
|
||||||
/// Passively listen for SSDP/UPnP NOTIFY announcements and feed discovered devices into the same
|
/// 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());
|
// The advertised SSDP NT device-type URNs rarely match the vendor lookup, but the call shape
|
||||||
// Privacy MACs are locally administered and have no real OUI, so the lookup above fails.
|
// mirrors the mDNS scanner so build_device can still deduce a vendor for privacy MACs.
|
||||||
// Fall back to the vendor-specific service strings the device advertises (SSDP NT URNs
|
let device = build_device(mac.clone(), src_ip.to_string(), &device_types, server_hint);
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
pipeline::record_sighting(device, DeviceEventScanner::Ssdp);
|
||||||
status::record_discovery(&mac);
|
status::record_discovery(&mac);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,26 @@
|
|||||||
use chrono::{DateTime, Duration, Utc};
|
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// A device counts as "seen" only if its most recent sighting falls within this
|
use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus};
|
||||||
/// rolling window.
|
|
||||||
const RECENT_WINDOW_SECONDS: i64 = 3600;
|
|
||||||
|
|
||||||
pub struct SsdpScannerStatus {
|
static STATUS: OnceCell<Mutex<PassiveStatus>> = OnceCell::new();
|
||||||
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();
|
|
||||||
|
|
||||||
pub fn init() {
|
pub fn init() {
|
||||||
STATUS
|
STATUS.set(Mutex::new(PassiveStatus::new())).ok();
|
||||||
.set(Mutex::new(SsdpScannerStatus {
|
|
||||||
is_listening: false,
|
|
||||||
listening_since: None,
|
|
||||||
recent_sightings: HashMap::new(),
|
|
||||||
last_discovery_at: None,
|
|
||||||
}))
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_listening() {
|
pub fn set_listening() {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let mut s = m.lock().unwrap();
|
m.lock().unwrap().set_listening();
|
||||||
s.is_listening = true;
|
|
||||||
s.listening_since = Some(Utc::now());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_discovery(mac: &str) {
|
pub fn record_discovery(mac: &str) {
|
||||||
if let Some(m) = STATUS.get() {
|
if let Some(m) = STATUS.get() {
|
||||||
let now = Utc::now();
|
m.lock().unwrap().record_discovery(mac);
|
||||||
let mut s = m.lock().unwrap();
|
|
||||||
s.recent_sightings.insert(mac.to_string(), now);
|
|
||||||
s.last_discovery_at = Some(now);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get() -> Option<SsdpScannerStatusSnapshot> {
|
pub fn get() -> Option<PassiveSnapshot> {
|
||||||
STATUS.get().map(|m| {
|
STATUS.get().map(|m| m.lock().unwrap().snapshot())
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ pub fn is_locally_administered(mac: &str) -> bool {
|
|||||||
.is_some_and(|first_octet| first_octet & 0x02 != 0)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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("Aa:Bb:Cc:Dd:Ee:Ff"), "aa:bb:cc:dd:ee:ff");
|
||||||
assert_eq!(normalize_mac(""), "");
|
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::devices::{Device, DeviceSummary};
|
||||||
use crate::model::notifications::{Notification, NotificationType};
|
use crate::model::notifications::{Notification, NotificationType};
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
use crate::web_server::arp_scanner::ArpScannerStatusResponse;
|
|
||||||
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
||||||
use crate::web_server::dhcp_scanner::DhcpScannerStatusResponse;
|
use crate::web_server::scanner_status::{
|
||||||
use crate::web_server::mdns_scanner::MdnsScannerStatusResponse;
|
ActiveScannerStatusResponse, PassiveScannerStatusResponse,
|
||||||
use crate::web_server::snmp_scanner::SnmpScannerStatusResponse;
|
};
|
||||||
use crate::web_server::ssdp_scanner::SsdpScannerStatusResponse;
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
@@ -33,6 +31,7 @@ pub mod devices;
|
|||||||
pub mod dhcp_scanner;
|
pub mod dhcp_scanner;
|
||||||
pub mod mdns_scanner;
|
pub mod mdns_scanner;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod scanner_status;
|
||||||
pub mod snmp_scanner;
|
pub mod snmp_scanner;
|
||||||
pub mod ssdp_scanner;
|
pub mod ssdp_scanner;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
@@ -74,11 +73,8 @@ pub mod utils;
|
|||||||
DeviceEvent,
|
DeviceEvent,
|
||||||
DeviceEventType,
|
DeviceEventType,
|
||||||
DeviceEventScanner,
|
DeviceEventScanner,
|
||||||
ArpScannerStatusResponse,
|
ActiveScannerStatusResponse,
|
||||||
MdnsScannerStatusResponse,
|
PassiveScannerStatusResponse,
|
||||||
SsdpScannerStatusResponse,
|
|
||||||
DhcpScannerStatusResponse,
|
|
||||||
SnmpScannerStatusResponse,
|
|
||||||
)),
|
)),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
tags(
|
tags(
|
||||||
|
|||||||
@@ -1,65 +1,17 @@
|
|||||||
use axum::{Json, http::StatusCode};
|
use axum::{Json, http::StatusCode};
|
||||||
use chrono::Utc;
|
|
||||||
use log::error;
|
|
||||||
use serde::Serialize;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_response};
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/arp_scanner/status",
|
path = "/api/arp_scanner/status",
|
||||||
tag = "arp_scanner",
|
tag = "arp_scanner",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "ARP scanner status", body = ArpScannerStatusResponse),
|
(status = 200, description = "ARP scanner status", body = ActiveScannerStatusResponse),
|
||||||
(status = 500, description = "Internal server error"),
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn status() -> Result<Json<ArpScannerStatusResponse>, StatusCode> {
|
pub async fn status() -> Result<Json<ActiveScannerStatusResponse>, StatusCode> {
|
||||||
let snapshot = match crate::scanners::arp::status::get() {
|
active_response(crate::scanners::arp::status::get()).map(Json)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,17 @@
|
|||||||
use axum::{Json, http::StatusCode};
|
use axum::{Json, http::StatusCode};
|
||||||
use chrono::Utc;
|
|
||||||
use log::error;
|
|
||||||
use serde::Serialize;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response};
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/dhcp_scanner/status",
|
path = "/api/dhcp_scanner/status",
|
||||||
tag = "dhcp_scanner",
|
tag = "dhcp_scanner",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "DHCP scanner status", body = DhcpScannerStatusResponse),
|
(status = 200, description = "DHCP scanner status", body = PassiveScannerStatusResponse),
|
||||||
(status = 500, description = "Internal server error"),
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn status() -> Result<Json<DhcpScannerStatusResponse>, StatusCode> {
|
pub async fn status() -> Result<Json<PassiveScannerStatusResponse>, StatusCode> {
|
||||||
let snapshot = match crate::scanners::dhcp::status::get() {
|
passive_response(crate::scanners::dhcp::status::get()).map(Json)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,17 @@
|
|||||||
use axum::{Json, http::StatusCode};
|
use axum::{Json, http::StatusCode};
|
||||||
use chrono::Utc;
|
|
||||||
use log::error;
|
|
||||||
use serde::Serialize;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response};
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/mdns_scanner/status",
|
path = "/api/mdns_scanner/status",
|
||||||
tag = "mdns_scanner",
|
tag = "mdns_scanner",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "mDNS scanner status", body = MdnsScannerStatusResponse),
|
(status = 200, description = "mDNS scanner status", body = PassiveScannerStatusResponse),
|
||||||
(status = 500, description = "Internal server error"),
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn status() -> Result<Json<MdnsScannerStatusResponse>, StatusCode> {
|
pub async fn status() -> Result<Json<PassiveScannerStatusResponse>, StatusCode> {
|
||||||
let snapshot = match crate::scanners::mdns::status::get() {
|
passive_response(crate::scanners::mdns::status::get()).map(Json)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 axum::{Json, http::StatusCode};
|
||||||
use chrono::Utc;
|
|
||||||
use log::error;
|
|
||||||
use serde::Serialize;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_response};
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/snmp_scanner/status",
|
path = "/api/snmp_scanner/status",
|
||||||
tag = "snmp_scanner",
|
tag = "snmp_scanner",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "SNMP scanner status", body = SnmpScannerStatusResponse),
|
(status = 200, description = "SNMP scanner status", body = ActiveScannerStatusResponse),
|
||||||
(status = 500, description = "Internal server error"),
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn status() -> Result<Json<SnmpScannerStatusResponse>, StatusCode> {
|
pub async fn status() -> Result<Json<ActiveScannerStatusResponse>, StatusCode> {
|
||||||
let snapshot = match crate::scanners::snmp::status::get() {
|
active_response(crate::scanners::snmp::status::get()).map(Json)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,17 @@
|
|||||||
use axum::{Json, http::StatusCode};
|
use axum::{Json, http::StatusCode};
|
||||||
use chrono::Utc;
|
|
||||||
use log::error;
|
|
||||||
use serde::Serialize;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response};
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/ssdp_scanner/status",
|
path = "/api/ssdp_scanner/status",
|
||||||
tag = "ssdp_scanner",
|
tag = "ssdp_scanner",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "SSDP scanner status", body = SsdpScannerStatusResponse),
|
(status = 200, description = "SSDP scanner status", body = PassiveScannerStatusResponse),
|
||||||
(status = 500, description = "Internal server error"),
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn status() -> Result<Json<SsdpScannerStatusResponse>, StatusCode> {
|
pub async fn status() -> Result<Json<PassiveScannerStatusResponse>, StatusCode> {
|
||||||
let snapshot = match crate::scanners::ssdp::status::get() {
|
passive_response(crate::scanners::ssdp::status::get()).map(Json)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user