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:
rzuasti
2026-06-02 10:14:50 -04:00
co-authored by Claude Opus 4.8
parent 72dbbe5e9a
commit 5f7a1287a1
28 changed files with 692 additions and 1125 deletions
+1
View File
@@ -1,4 +1,5 @@
pub mod arp;
pub mod common;
pub mod dhcp;
pub mod error;
pub mod mdns;
@@ -1,7 +1,5 @@
use crate::data::mac_vendor_finder;
use crate::data::vendor_device_type_finder;
use crate::model::devices::Device;
use chrono::Local;
use crate::scanners::common::enrichment::build_device;
use duration_string::DurationString;
use log::{debug, info, trace};
use pnet::datalink::{DataLinkReceiver, DataLinkSender, NetworkInterface};
@@ -99,21 +97,10 @@ pub async fn listen_for_packets(
if arp_packet.get_target_proto_addr() == ipv4_net.ip() {
let packet_mac_address = arp_packet.get_sender_hw_addr().to_string();
let packet_ip_address = arp_packet.get_sender_proto_addr().to_string();
let packet_vendor = mac_vendor_finder::find(
packet_mac_address.get(0..8).unwrap_or("").to_string(),
);
debug!(
"Found online device - IP addr={} - MAC addr={} - vendor={}",
packet_ip_address, packet_mac_address, packet_vendor
);
let mut device = Device::new(
packet_mac_address,
packet_ip_address,
packet_vendor,
Local::now().to_utc(),
);
device.device_type = vendor_device_type_finder::find(&device.vendor);
// ARP carries no hostname or service hints, so vendor comes from the OUI only.
let device = build_device(packet_mac_address, packet_ip_address, &[], None);
debug!("Found online device {device}");
devices.push(device);
}
}
+2 -38
View File
@@ -1,8 +1,7 @@
use super::finder;
use super::status;
use crate::db;
use crate::events;
use crate::model::device_events::DeviceEventScanner;
use crate::scanners::common::pipeline;
use crate::settings::get_settings;
use chrono::Utc;
use log::{debug, info};
@@ -27,42 +26,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
// Process found devices
for device in devices.iter() {
debug!("Online device found {}", device);
// Read device from database
let recorded_device_result = db::devices::read(device.mac_address.clone());
match recorded_device_result {
Some(recorded_device) => {
// If it exists update its last seen date
debug!(
"Device found in database {}. Updating to {}.",
recorded_device, device
);
db::devices::seen(
device.mac_address.clone(),
device.ipv4_address.clone(),
device.vendor.clone(),
device.device_type.clone(),
device.name.clone(),
)?;
events::trigger_existing_device(
recorded_device,
device.clone(),
DeviceEventScanner::Arp,
)
.ok(); // Ignoring errors here, do not stop loop if notification delivery fails
}
None => {
// If it doesn't exist insert it
debug!(
"Device with MAC address {} not found in database. Inserting it.",
device.mac_address
);
db::devices::insert(device.clone())?;
events::trigger_new_device(device.clone(), DeviceEventScanner::Arp).ok(); // Ignoring errors here, do not stop loop if notification delivery fails
}
};
pipeline::record_sighting(device.clone(), DeviceEventScanner::Arp);
}
let wait = Duration::from(get_settings().arp_scanner.wait_between_scans);
+8 -107
View File
@@ -2,131 +2,32 @@ use chrono::{DateTime, Utc};
use once_cell::sync::OnceCell;
use std::sync::Mutex;
pub struct ArpScannerStatus {
pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>,
pub last_scan_devices_seen: Option<u64>,
pub last_scan_at: Option<DateTime<Utc>>,
}
use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus};
#[derive(Clone)]
pub struct ArpScannerStatusSnapshot {
pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>,
pub last_scan_devices_seen: Option<u64>,
pub last_scan_at: Option<DateTime<Utc>>,
}
static STATUS: OnceCell<Mutex<ArpScannerStatus>> = OnceCell::new();
static STATUS: OnceCell<Mutex<ActiveStatus>> = OnceCell::new();
pub fn init() {
STATUS
.set(Mutex::new(ArpScannerStatus {
is_running: false,
scan_started_at: None,
next_scan_at: None,
last_scan_devices_seen: None,
last_scan_at: None,
}))
.ok();
STATUS.set(Mutex::new(ActiveStatus::new())).ok();
}
pub fn set_running() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_running = true;
s.scan_started_at = Some(Utc::now());
s.next_scan_at = None;
m.lock().unwrap().set_running();
}
}
pub fn set_waiting(next_scan_at: DateTime<Utc>) {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_running = false;
s.scan_started_at = None;
s.next_scan_at = Some(next_scan_at);
m.lock().unwrap().set_waiting(next_scan_at);
}
}
pub fn record_scan(devices_seen: u64) {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.last_scan_devices_seen = Some(devices_seen);
s.last_scan_at = Some(Utc::now());
m.lock().unwrap().record_scan(devices_seen);
}
}
pub fn get() -> Option<ArpScannerStatusSnapshot> {
STATUS.get().map(|m| {
let s = m.lock().unwrap();
ArpScannerStatusSnapshot {
is_running: s.is_running,
scan_started_at: s.scan_started_at,
next_scan_at: s.next_scan_at,
last_scan_devices_seen: s.last_scan_devices_seen,
last_scan_at: s.last_scan_at,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_for_test() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_running = false;
s.scan_started_at = None;
s.next_scan_at = None;
s.last_scan_devices_seen = None;
s.last_scan_at = None;
} else {
init();
}
}
#[test]
fn test_set_running() {
reset_for_test();
set_running();
let snapshot = get().unwrap();
assert!(snapshot.is_running);
assert!(snapshot.scan_started_at.is_some());
assert!(snapshot.next_scan_at.is_none());
}
#[test]
fn test_set_waiting() {
reset_for_test();
let next = Utc::now() + chrono::Duration::seconds(60);
set_waiting(next);
let snapshot = get().unwrap();
assert!(!snapshot.is_running);
assert!(snapshot.scan_started_at.is_none());
assert_eq!(snapshot.next_scan_at.unwrap(), next);
}
#[test]
fn test_record_scan() {
reset_for_test();
record_scan(7);
let snapshot = get().unwrap();
assert_eq!(snapshot.last_scan_devices_seen, Some(7));
assert!(snapshot.last_scan_at.is_some());
}
#[test]
fn test_initial_state() {
reset_for_test();
let snapshot = get().unwrap();
assert!(!snapshot.is_running);
assert!(snapshot.scan_started_at.is_none());
assert!(snapshot.next_scan_at.is_none());
assert!(snapshot.last_scan_devices_seen.is_none());
assert!(snapshot.last_scan_at.is_none());
}
pub fn get() -> Option<ActiveSnapshot> {
STATUS.get().map(|m| m.lock().unwrap().snapshot())
}
+10
View File
@@ -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());
}
}
+71
View File
@@ -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());
}
}
+102
View File
@@ -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");
}
}
+67 -19
View File
@@ -4,6 +4,8 @@ use log::{debug, info};
use socket2::{Domain, Protocol, Socket, Type};
use tokio::net::UdpSocket;
use crate::utils::network::format_mac;
const DHCP_SERVER_PORT: u16 = 67;
// DHCP/BOOTP fixed-header field offsets (RFC 2131).
@@ -162,15 +164,6 @@ pub fn parse_packet(buf: &[u8]) -> Option<DhcpDiscovery> {
})
}
/// Format 6 raw MAC bytes as a lowercase colon-separated string.
fn format_mac(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -184,7 +177,8 @@ mod tests {
let ci = ciaddr.octets();
buf[CIADDR_OFFSET..CIADDR_OFFSET + 4].copy_from_slice(&ci);
// chaddr: aa:bb:cc:dd:ee:ff
buf[CHADDR_OFFSET..CHADDR_OFFSET + 6].copy_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
buf[CHADDR_OFFSET..CHADDR_OFFSET + 6]
.copy_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
buf[MAGIC_COOKIE_OFFSET..OPTIONS_OFFSET].copy_from_slice(&MAGIC_COOKIE);
buf.extend_from_slice(options);
buf.push(OPTION_END);
@@ -201,7 +195,13 @@ mod tests {
options.extend_from_slice(&[OPTION_HOSTNAME, 6, b'l', b'a', b'p', b't', b'o', b'p']);
options.extend_from_slice(&[OPTION_REQUESTED_IP, 4, 192, 168, 1, 50]);
let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options);
let buf = build_packet(
OP_BOOTREQUEST,
HTYPE_ETHERNET,
HLEN_ETHERNET,
Ipv4Addr::UNSPECIFIED,
&options,
);
let parsed = parse_packet(&buf).expect("discover packet");
assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff");
assert_eq!(parsed.hostname.as_deref(), Some("laptop"));
@@ -229,7 +229,13 @@ mod tests {
#[test]
fn test_parse_discover_without_ip_yields_none_hint() {
let options = message_type_option(DHCP_DISCOVER);
let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options);
let buf = build_packet(
OP_BOOTREQUEST,
HTYPE_ETHERNET,
HLEN_ETHERNET,
Ipv4Addr::UNSPECIFIED,
&options,
);
let parsed = parse_packet(&buf).expect("discover packet");
assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff");
assert_eq!(parsed.hostname, None);
@@ -240,7 +246,13 @@ mod tests {
fn test_parse_server_reply_returns_none() {
// op = 2 (BOOTREPLY) — a server message, ignored.
let options = message_type_option(DHCP_DISCOVER);
let buf = build_packet(2, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options);
let buf = build_packet(
2,
HTYPE_ETHERNET,
HLEN_ETHERNET,
Ipv4Addr::UNSPECIFIED,
&options,
);
assert!(parse_packet(&buf).is_none());
}
@@ -248,7 +260,13 @@ mod tests {
fn test_parse_other_message_type_returns_none() {
// message type 5 = ACK (server->client), not a client request.
let options = message_type_option(5);
let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options);
let buf = build_packet(
OP_BOOTREQUEST,
HTYPE_ETHERNET,
HLEN_ETHERNET,
Ipv4Addr::UNSPECIFIED,
&options,
);
assert!(parse_packet(&buf).is_none());
}
@@ -256,14 +274,26 @@ mod tests {
fn test_parse_missing_message_type_returns_none() {
// No option 53 present at all.
let options = vec![OPTION_HOSTNAME, 4, b'h', b'o', b's', b't'];
let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options);
let buf = build_packet(
OP_BOOTREQUEST,
HTYPE_ETHERNET,
HLEN_ETHERNET,
Ipv4Addr::UNSPECIFIED,
&options,
);
assert!(parse_packet(&buf).is_none());
}
#[test]
fn test_parse_bad_magic_cookie_returns_none() {
let options = message_type_option(DHCP_DISCOVER);
let mut buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options);
let mut buf = build_packet(
OP_BOOTREQUEST,
HTYPE_ETHERNET,
HLEN_ETHERNET,
Ipv4Addr::UNSPECIFIED,
&options,
);
buf[MAGIC_COOKIE_OFFSET] = 0;
assert!(parse_packet(&buf).is_none());
}
@@ -272,10 +302,22 @@ mod tests {
fn test_parse_non_ethernet_returns_none() {
let options = message_type_option(DHCP_DISCOVER);
// htype != 1
let buf = build_packet(OP_BOOTREQUEST, 6, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options);
let buf = build_packet(
OP_BOOTREQUEST,
6,
HLEN_ETHERNET,
Ipv4Addr::UNSPECIFIED,
&options,
);
assert!(parse_packet(&buf).is_none());
// hlen != 6
let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, 8, Ipv4Addr::UNSPECIFIED, &options);
let buf = build_packet(
OP_BOOTREQUEST,
HTYPE_ETHERNET,
8,
Ipv4Addr::UNSPECIFIED,
&options,
);
assert!(parse_packet(&buf).is_none());
}
@@ -290,7 +332,13 @@ mod tests {
// Option claims 4 bytes of payload but the buffer ends early. Must not panic and
// must not yield a discovery (message type never read).
let options = vec![OPTION_REQUESTED_IP, 4, 192, 168];
let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options);
let buf = build_packet(
OP_BOOTREQUEST,
HTYPE_ETHERNET,
HLEN_ETHERNET,
Ipv4Addr::UNSPECIFIED,
&options,
);
// Drop the trailing END byte appended by build_packet to keep the option truncated.
let truncated = &buf[..buf.len() - 1];
assert!(parse_packet(truncated).is_none());
+15 -60
View File
@@ -1,14 +1,10 @@
use chrono::Local;
use log::{debug, error, info, warn};
use log::{info, warn};
use super::finder;
use super::status;
use crate::data::mac_vendor_finder;
use crate::data::vendor_device_type_finder;
use crate::db;
use crate::events;
use crate::model::device_events::DeviceEventScanner;
use crate::model::devices::Device;
use crate::scanners::common::enrichment::build_device;
use crate::scanners::common::pipeline;
use crate::settings::get_settings;
/// Passively snoop DHCP DISCOVER/REQUEST broadcasts and feed discovered devices into the
@@ -40,64 +36,23 @@ pub async fn listen() -> Result<(), Box<dyn std::error::Error>> {
None => continue, // server reply, non-Ethernet, other message type, garbage
};
process_discovery(discovery).await;
process_discovery(discovery);
}
}
async fn process_discovery(discovery: finder::DhcpDiscovery) {
fn process_discovery(discovery: finder::DhcpDiscovery) {
let mac = discovery.mac;
// Unlike mDNS/SSDP, the MAC is carried in the packet (chaddr), so no ARP probe is
// needed. Privacy/locally-administered MACs have no real OUI, so the lookup returns
// an empty vendor; there is no DHCP equivalent of a service list to fall back on, so
// it is left empty (the DB layer preserves any previously known vendor).
let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
let ip = discovery.ip_hint.map(|ip| ip.to_string());
let mut device = Device::new(
mac.clone(),
ip.clone().unwrap_or_default(),
vendor,
Local::now().to_utc(),
);
device.device_type = vendor_device_type_finder::find(&device.vendor);
device.name = discovery.hostname;
match db::devices::read(mac.clone()) {
Some(recorded) => {
debug!("DHCP sighting of known device {mac}; updating");
// Keep a previously stored name (likely a richer hostname from mDNS) rather
// than overwriting it.
if recorded.name.is_some() {
device.name = recorded.name.clone();
}
// A DISCOVER carries no assigned IP. Don't clobber a known good address with an
// empty string; reuse the recorded one when this packet has no IP hint.
if ip.is_none() {
device.ipv4_address = recorded.ipv4_address.clone();
}
if let Err(err) = db::devices::seen(
device.mac_address.clone(),
device.ipv4_address.clone(),
device.vendor.clone(),
device.device_type.clone(),
device.name.clone(),
) {
error!("Failed to update DHCP device {mac}: {err}");
return;
}
// Ignoring errors: do not stop the listener if notification delivery fails
events::trigger_existing_device(recorded, device, DeviceEventScanner::Dhcp).ok();
}
None => {
debug!("New device {mac} discovered via DHCP; inserting");
if let Err(err) = db::devices::insert(device.clone()) {
error!("Failed to insert DHCP device {mac}: {err}");
return;
}
events::trigger_new_device(device, DeviceEventScanner::Dhcp).ok();
}
}
// Unlike mDNS/SSDP, the MAC is carried in the packet (chaddr), so no ARP probe is needed.
// DHCP advertises no service list, so there is no vendor fallback (pass no service hints); a
// privacy MAC therefore yields an empty vendor, which the DB layer preserves. A DISCOVER
// carries no assigned IP, so the address may be empty here — the pipeline keeps any known IP.
let ipv4 = discovery
.ip_hint
.map(|ip| ip.to_string())
.unwrap_or_default();
let device = build_device(mac.clone(), ipv4, &[], discovery.hostname);
pipeline::record_sighting(device, DeviceEventScanner::Dhcp);
status::record_discovery(&mac);
}
+7 -122
View File
@@ -1,141 +1,26 @@
use chrono::{DateTime, Duration, Utc};
use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::sync::Mutex;
/// A device counts as "seen" only if its most recent sighting falls within this
/// rolling window.
const RECENT_WINDOW_SECONDS: i64 = 3600;
use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus};
pub struct DhcpScannerStatus {
pub is_listening: bool,
pub listening_since: Option<DateTime<Utc>>,
/// Most recent sighting time per device MAC, used to count the distinct
/// devices seen within the last hour.
pub recent_sightings: HashMap<String, DateTime<Utc>>,
pub last_discovery_at: Option<DateTime<Utc>>,
}
#[derive(Clone)]
pub struct DhcpScannerStatusSnapshot {
pub is_listening: bool,
pub listening_since: Option<DateTime<Utc>>,
/// Distinct devices seen within the last hour.
pub devices_last_hour: u64,
pub last_discovery_at: Option<DateTime<Utc>>,
}
static STATUS: OnceCell<Mutex<DhcpScannerStatus>> = OnceCell::new();
static STATUS: OnceCell<Mutex<PassiveStatus>> = OnceCell::new();
pub fn init() {
STATUS
.set(Mutex::new(DhcpScannerStatus {
is_listening: false,
listening_since: None,
recent_sightings: HashMap::new(),
last_discovery_at: None,
}))
.ok();
STATUS.set(Mutex::new(PassiveStatus::new())).ok();
}
pub fn set_listening() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_listening = true;
s.listening_since = Some(Utc::now());
m.lock().unwrap().set_listening();
}
}
pub fn record_discovery(mac: &str) {
if let Some(m) = STATUS.get() {
let now = Utc::now();
let mut s = m.lock().unwrap();
s.recent_sightings.insert(mac.to_string(), now);
s.last_discovery_at = Some(now);
m.lock().unwrap().record_discovery(mac);
}
}
pub fn get() -> Option<DhcpScannerStatusSnapshot> {
STATUS.get().map(|m| {
let mut s = m.lock().unwrap();
let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS);
s.recent_sightings.retain(|_, seen| *seen >= cutoff);
DhcpScannerStatusSnapshot {
is_listening: s.is_listening,
listening_since: s.listening_since,
devices_last_hour: s.recent_sightings.len() as u64,
last_discovery_at: s.last_discovery_at,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_for_test() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_listening = false;
s.listening_since = None;
s.recent_sightings.clear();
s.last_discovery_at = None;
} else {
init();
}
}
#[test]
fn test_set_listening() {
reset_for_test();
set_listening();
let snapshot = get().unwrap();
assert!(snapshot.is_listening);
assert!(snapshot.listening_since.is_some());
}
#[test]
fn test_same_mac_counts_once() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
record_discovery("aa:bb:cc:dd:ee:ff");
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 1);
assert!(snapshot.last_discovery_at.is_some());
}
#[test]
fn test_distinct_macs_counted() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
record_discovery("11:22:33:44:55:66");
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 2);
}
#[test]
fn test_old_sighting_excluded() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
// Backdate an entry beyond the window; it must not be counted.
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.recent_sightings.insert(
"11:22:33:44:55:66".to_string(),
Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60),
);
}
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 1);
}
#[test]
fn test_initial_state() {
reset_for_test();
let snapshot = get().unwrap();
assert!(!snapshot.is_listening);
assert!(snapshot.listening_since.is_none());
assert_eq!(snapshot.devices_last_hour, 0);
assert!(snapshot.last_discovery_at.is_none());
}
pub fn get() -> Option<PassiveSnapshot> {
STATUS.get().map(|m| m.lock().unwrap().snapshot())
}
+9 -49
View File
@@ -1,17 +1,13 @@
use std::net::{IpAddr, Ipv4Addr};
use std::time::Duration;
use chrono::Local;
use log::{debug, error, info, warn};
use log::{debug, info, warn};
use super::finder;
use super::status;
use crate::data::mac_vendor_finder;
use crate::data::vendor_device_type_finder;
use crate::db;
use crate::events;
use crate::model::device_events::DeviceEventScanner;
use crate::model::devices::Device;
use crate::scanners::common::enrichment::build_device;
use crate::scanners::common::pipeline;
use crate::settings::get_settings;
/// Passively listen for mDNS/Bonjour announcements and feed discovered devices into the same
@@ -77,51 +73,15 @@ async fn process_announcement(
}
};
let mut vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
// Privacy MACs are locally administered and have no real OUI, so the lookup above fails.
// Fall back to the vendor-specific mDNS services the device advertises.
if vendor.is_empty() && crate::utils::network::is_locally_administered(&mac) {
vendor = crate::data::service_vendor_finder::find(&service_types);
}
let mut device = Device::new(
// The advertised mDNS service types let build_device deduce a vendor for privacy MACs whose
// OUI lookup fails.
let device = build_device(
mac.clone(),
src_ip.to_string(),
vendor,
Local::now().to_utc(),
&service_types,
Some(hostname),
);
device.device_type = vendor_device_type_finder::find(&device.vendor);
device.name = Some(hostname);
match db::devices::read(mac.clone()) {
Some(recorded) => {
debug!("mDNS sighting of known device {mac}; updating");
// Keep the previously stored hostname rather than overwriting it with this
// announcement's hostname.
if recorded.name.is_some() {
device.name = recorded.name.clone();
}
if let Err(err) = db::devices::seen(
device.mac_address.clone(),
device.ipv4_address.clone(),
device.vendor.clone(),
device.device_type.clone(),
device.name.clone(),
) {
error!("Failed to update mDNS device {mac}: {err}");
return;
}
// Ignoring errors: do not stop the listener if notification delivery fails
events::trigger_existing_device(recorded, device, DeviceEventScanner::Mdns).ok();
}
None => {
debug!("New device {mac} discovered via mDNS; inserting");
if let Err(err) = db::devices::insert(device.clone()) {
error!("Failed to insert mDNS device {mac}: {err}");
return;
}
events::trigger_new_device(device, DeviceEventScanner::Mdns).ok();
}
}
pipeline::record_sighting(device, DeviceEventScanner::Mdns);
status::record_discovery(&mac);
}
+7 -122
View File
@@ -1,141 +1,26 @@
use chrono::{DateTime, Duration, Utc};
use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::sync::Mutex;
/// A device counts as "seen" only if its most recent sighting falls within this
/// rolling window.
const RECENT_WINDOW_SECONDS: i64 = 3600;
use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus};
pub struct MdnsScannerStatus {
pub is_listening: bool,
pub listening_since: Option<DateTime<Utc>>,
/// Most recent sighting time per device MAC, used to count the distinct
/// devices seen within the last hour.
pub recent_sightings: HashMap<String, DateTime<Utc>>,
pub last_discovery_at: Option<DateTime<Utc>>,
}
#[derive(Clone)]
pub struct MdnsScannerStatusSnapshot {
pub is_listening: bool,
pub listening_since: Option<DateTime<Utc>>,
/// Distinct devices seen within the last hour.
pub devices_last_hour: u64,
pub last_discovery_at: Option<DateTime<Utc>>,
}
static STATUS: OnceCell<Mutex<MdnsScannerStatus>> = OnceCell::new();
static STATUS: OnceCell<Mutex<PassiveStatus>> = OnceCell::new();
pub fn init() {
STATUS
.set(Mutex::new(MdnsScannerStatus {
is_listening: false,
listening_since: None,
recent_sightings: HashMap::new(),
last_discovery_at: None,
}))
.ok();
STATUS.set(Mutex::new(PassiveStatus::new())).ok();
}
pub fn set_listening() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_listening = true;
s.listening_since = Some(Utc::now());
m.lock().unwrap().set_listening();
}
}
pub fn record_discovery(mac: &str) {
if let Some(m) = STATUS.get() {
let now = Utc::now();
let mut s = m.lock().unwrap();
s.recent_sightings.insert(mac.to_string(), now);
s.last_discovery_at = Some(now);
m.lock().unwrap().record_discovery(mac);
}
}
pub fn get() -> Option<MdnsScannerStatusSnapshot> {
STATUS.get().map(|m| {
let mut s = m.lock().unwrap();
let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS);
s.recent_sightings.retain(|_, seen| *seen >= cutoff);
MdnsScannerStatusSnapshot {
is_listening: s.is_listening,
listening_since: s.listening_since,
devices_last_hour: s.recent_sightings.len() as u64,
last_discovery_at: s.last_discovery_at,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_for_test() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_listening = false;
s.listening_since = None;
s.recent_sightings.clear();
s.last_discovery_at = None;
} else {
init();
}
}
#[test]
fn test_set_listening() {
reset_for_test();
set_listening();
let snapshot = get().unwrap();
assert!(snapshot.is_listening);
assert!(snapshot.listening_since.is_some());
}
#[test]
fn test_same_mac_counts_once() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
record_discovery("aa:bb:cc:dd:ee:ff");
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 1);
assert!(snapshot.last_discovery_at.is_some());
}
#[test]
fn test_distinct_macs_counted() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
record_discovery("11:22:33:44:55:66");
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 2);
}
#[test]
fn test_old_sighting_excluded() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
// Backdate an entry beyond the window; it must not be counted.
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.recent_sightings.insert(
"11:22:33:44:55:66".to_string(),
Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60),
);
}
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 1);
}
#[test]
fn test_initial_state() {
reset_for_test();
let snapshot = get().unwrap();
assert!(!snapshot.is_listening);
assert!(snapshot.listening_since.is_none());
assert_eq!(snapshot.devices_last_hour, 0);
assert!(snapshot.last_discovery_at.is_none());
}
pub fn get() -> Option<PassiveSnapshot> {
STATUS.get().map(|m| m.lock().unwrap().snapshot())
}
+15 -26
View File
@@ -1,13 +1,12 @@
use std::net::{Ipv4Addr, SocketAddr};
use chrono::Local;
use csnmp::{ObjectIdentifier, ObjectValue, Snmp2cClient};
use log::{debug, info, warn};
use crate::data::mac_vendor_finder;
use crate::data::vendor_device_type_finder;
use crate::model::devices::Device;
use crate::scanners::common::enrichment::build_device;
use crate::settings::SnmpScanner;
use crate::utils::network::format_mac;
// ipNetToMediaPhysAddress (RFC 1213 MIB-II): maps interface + IPv4 address to a MAC. Walking
// this column yields one row per neighbour in the agent's ARP cache. Rows are indexed by
@@ -43,12 +42,7 @@ pub async fn find(config: &SnmpScanner) -> Result<Vec<Device>, Box<dyn std::erro
let devices = pairs
.into_iter()
.map(|(mac, ip)| {
let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
let mut device = Device::new(mac, ip.to_string(), vendor, Local::now().to_utc());
device.device_type = vendor_device_type_finder::find(&device.vendor);
device
})
.map(|(mac, ip)| build_device(mac, ip.to_string(), &[], None))
.collect();
Ok(devices)
@@ -103,15 +97,6 @@ where
pairs
}
/// Format 6 raw MAC bytes as a lowercase colon-separated string.
fn format_mac(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -141,7 +126,10 @@ mod tests {
let pairs = parse_arp_table(rows, &base());
assert_eq!(
pairs,
vec![("aa:bb:cc:dd:ee:ff".to_string(), Ipv4Addr::new(192, 168, 1, 50))]
vec![(
"aa:bb:cc:dd:ee:ff".to_string(),
Ipv4Addr::new(192, 168, 1, 50)
)]
);
}
@@ -180,12 +168,13 @@ mod tests {
let pairs = parse_arp_table(rows, &base());
assert_eq!(pairs.len(), 2);
assert!(pairs.contains(&("00:11:22:33:44:55".to_string(), Ipv4Addr::new(192, 168, 1, 2))));
assert!(pairs.contains(&("66:77:88:99:aa:bb".to_string(), Ipv4Addr::new(192, 168, 1, 3))));
}
#[test]
fn format_mac_pads_and_lowercases() {
assert_eq!(format_mac(&[0x0a, 0x00, 0xff, 0x10, 0x20, 0x30]), "0a:00:ff:10:20:30");
assert!(pairs.contains(&(
"00:11:22:33:44:55".to_string(),
Ipv4Addr::new(192, 168, 1, 2)
)));
assert!(pairs.contains(&(
"66:77:88:99:aa:bb".to_string(),
Ipv4Addr::new(192, 168, 1, 3)
)));
}
}
+3 -36
View File
@@ -1,11 +1,10 @@
use super::finder;
use super::status;
use crate::db;
use crate::events;
use crate::model::device_events::DeviceEventScanner;
use crate::scanners::common::pipeline;
use crate::settings::get_settings;
use chrono::Utc;
use log::{debug, error, info};
use log::{error, info};
use tokio::time::{Duration, sleep};
/// Periodically poll an SNMP agent (typically the gateway) for its ARP/neighbour cache and feed
@@ -35,7 +34,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
info!("SNMP poll found {} devices in the ARP cache", devices.len());
status::record_scan(devices.len() as u64);
for device in devices.iter() {
process_device(device);
pipeline::record_sighting(device.clone(), DeviceEventScanner::Snmp);
}
}
Err(err) => error!("SNMP poll of {} failed: {err}", config.target),
@@ -51,35 +50,3 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
sleep(wait).await;
}
}
fn process_device(device: &crate::model::devices::Device) {
match db::devices::read(device.mac_address.clone()) {
Some(recorded_device) => {
debug!("SNMP sighting of known device {}; updating", device.mac_address);
if let Err(err) = db::devices::seen(
device.mac_address.clone(),
device.ipv4_address.clone(),
device.vendor.clone(),
device.device_type.clone(),
device.name.clone(),
) {
error!("Failed to update SNMP device {}: {err}", device.mac_address);
return;
}
// Ignoring errors: do not stop the loop if notification delivery fails.
events::trigger_existing_device(recorded_device, device.clone(), DeviceEventScanner::Snmp)
.ok();
}
None => {
debug!(
"New device {} discovered via SNMP; inserting",
device.mac_address
);
if let Err(err) = db::devices::insert(device.clone()) {
error!("Failed to insert SNMP device {}: {err}", device.mac_address);
return;
}
events::trigger_new_device(device.clone(), DeviceEventScanner::Snmp).ok();
}
}
}
+8 -107
View File
@@ -2,131 +2,32 @@ use chrono::{DateTime, Utc};
use once_cell::sync::OnceCell;
use std::sync::Mutex;
pub struct SnmpScannerStatus {
pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>,
pub last_scan_devices_seen: Option<u64>,
pub last_scan_at: Option<DateTime<Utc>>,
}
use crate::scanners::common::active_status::{ActiveSnapshot, ActiveStatus};
#[derive(Clone)]
pub struct SnmpScannerStatusSnapshot {
pub is_running: bool,
pub scan_started_at: Option<DateTime<Utc>>,
pub next_scan_at: Option<DateTime<Utc>>,
pub last_scan_devices_seen: Option<u64>,
pub last_scan_at: Option<DateTime<Utc>>,
}
static STATUS: OnceCell<Mutex<SnmpScannerStatus>> = OnceCell::new();
static STATUS: OnceCell<Mutex<ActiveStatus>> = OnceCell::new();
pub fn init() {
STATUS
.set(Mutex::new(SnmpScannerStatus {
is_running: false,
scan_started_at: None,
next_scan_at: None,
last_scan_devices_seen: None,
last_scan_at: None,
}))
.ok();
STATUS.set(Mutex::new(ActiveStatus::new())).ok();
}
pub fn set_running() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_running = true;
s.scan_started_at = Some(Utc::now());
s.next_scan_at = None;
m.lock().unwrap().set_running();
}
}
pub fn set_waiting(next_scan_at: DateTime<Utc>) {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_running = false;
s.scan_started_at = None;
s.next_scan_at = Some(next_scan_at);
m.lock().unwrap().set_waiting(next_scan_at);
}
}
pub fn record_scan(devices_seen: u64) {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.last_scan_devices_seen = Some(devices_seen);
s.last_scan_at = Some(Utc::now());
m.lock().unwrap().record_scan(devices_seen);
}
}
pub fn get() -> Option<SnmpScannerStatusSnapshot> {
STATUS.get().map(|m| {
let s = m.lock().unwrap();
SnmpScannerStatusSnapshot {
is_running: s.is_running,
scan_started_at: s.scan_started_at,
next_scan_at: s.next_scan_at,
last_scan_devices_seen: s.last_scan_devices_seen,
last_scan_at: s.last_scan_at,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_for_test() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_running = false;
s.scan_started_at = None;
s.next_scan_at = None;
s.last_scan_devices_seen = None;
s.last_scan_at = None;
} else {
init();
}
}
#[test]
fn test_set_running() {
reset_for_test();
set_running();
let snapshot = get().unwrap();
assert!(snapshot.is_running);
assert!(snapshot.scan_started_at.is_some());
assert!(snapshot.next_scan_at.is_none());
}
#[test]
fn test_set_waiting() {
reset_for_test();
let next = Utc::now() + chrono::Duration::seconds(60);
set_waiting(next);
let snapshot = get().unwrap();
assert!(!snapshot.is_running);
assert!(snapshot.scan_started_at.is_none());
assert_eq!(snapshot.next_scan_at.unwrap(), next);
}
#[test]
fn test_record_scan() {
reset_for_test();
record_scan(7);
let snapshot = get().unwrap();
assert_eq!(snapshot.last_scan_devices_seen, Some(7));
assert!(snapshot.last_scan_at.is_some());
}
#[test]
fn test_initial_state() {
reset_for_test();
let snapshot = get().unwrap();
assert!(!snapshot.is_running);
assert!(snapshot.scan_started_at.is_none());
assert!(snapshot.next_scan_at.is_none());
assert!(snapshot.last_scan_devices_seen.is_none());
assert!(snapshot.last_scan_at.is_none());
}
pub fn get() -> Option<ActiveSnapshot> {
STATUS.get().map(|m| m.lock().unwrap().snapshot())
}
+7 -53
View File
@@ -1,17 +1,13 @@
use std::net::{IpAddr, Ipv4Addr};
use std::time::Duration;
use chrono::Local;
use log::{debug, error, info, warn};
use log::{debug, info, warn};
use super::finder;
use super::status;
use crate::data::mac_vendor_finder;
use crate::data::vendor_device_type_finder;
use crate::db;
use crate::events;
use crate::model::device_events::DeviceEventScanner;
use crate::model::devices::Device;
use crate::scanners::common::enrichment::build_device;
use crate::scanners::common::pipeline;
use crate::settings::get_settings;
/// Passively listen for SSDP/UPnP NOTIFY announcements and feed discovered devices into the same
@@ -76,52 +72,10 @@ async fn process_announcement(
}
};
let mut vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
// Privacy MACs are locally administered and have no real OUI, so the lookup above fails.
// Fall back to the vendor-specific service strings the device advertises (SSDP NT URNs
// typically won't match this lookup, but the call shape mirrors the mDNS scanner).
if vendor.is_empty() && crate::utils::network::is_locally_administered(&mac) {
vendor = crate::data::service_vendor_finder::find(&device_types);
}
let mut device = Device::new(
mac.clone(),
src_ip.to_string(),
vendor,
Local::now().to_utc(),
);
device.device_type = vendor_device_type_finder::find(&device.vendor);
device.name = server_hint;
match db::devices::read(mac.clone()) {
Some(recorded) => {
debug!("SSDP sighting of known device {mac}; updating");
// Keep the previously stored name (likely a proper hostname from mDNS) rather than
// overwriting it with the SERVER header string.
if recorded.name.is_some() {
device.name = recorded.name.clone();
}
if let Err(err) = db::devices::seen(
device.mac_address.clone(),
device.ipv4_address.clone(),
device.vendor.clone(),
device.device_type.clone(),
device.name.clone(),
) {
error!("Failed to update SSDP device {mac}: {err}");
return;
}
// Ignoring errors: do not stop the listener if notification delivery fails
events::trigger_existing_device(recorded, device, DeviceEventScanner::Ssdp).ok();
}
None => {
debug!("New device {mac} discovered via SSDP; inserting");
if let Err(err) = db::devices::insert(device.clone()) {
error!("Failed to insert SSDP device {mac}: {err}");
return;
}
events::trigger_new_device(device, DeviceEventScanner::Ssdp).ok();
}
}
// The advertised SSDP NT device-type URNs rarely match the vendor lookup, but the call shape
// mirrors the mDNS scanner so build_device can still deduce a vendor for privacy MACs.
let device = build_device(mac.clone(), src_ip.to_string(), &device_types, server_hint);
pipeline::record_sighting(device, DeviceEventScanner::Ssdp);
status::record_discovery(&mac);
}
+7 -122
View File
@@ -1,141 +1,26 @@
use chrono::{DateTime, Duration, Utc};
use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::sync::Mutex;
/// A device counts as "seen" only if its most recent sighting falls within this
/// rolling window.
const RECENT_WINDOW_SECONDS: i64 = 3600;
use crate::scanners::common::passive_status::{PassiveSnapshot, PassiveStatus};
pub struct SsdpScannerStatus {
pub is_listening: bool,
pub listening_since: Option<DateTime<Utc>>,
/// Most recent sighting time per device MAC, used to count the distinct
/// devices seen within the last hour.
pub recent_sightings: HashMap<String, DateTime<Utc>>,
pub last_discovery_at: Option<DateTime<Utc>>,
}
#[derive(Clone)]
pub struct SsdpScannerStatusSnapshot {
pub is_listening: bool,
pub listening_since: Option<DateTime<Utc>>,
/// Distinct devices seen within the last hour.
pub devices_last_hour: u64,
pub last_discovery_at: Option<DateTime<Utc>>,
}
static STATUS: OnceCell<Mutex<SsdpScannerStatus>> = OnceCell::new();
static STATUS: OnceCell<Mutex<PassiveStatus>> = OnceCell::new();
pub fn init() {
STATUS
.set(Mutex::new(SsdpScannerStatus {
is_listening: false,
listening_since: None,
recent_sightings: HashMap::new(),
last_discovery_at: None,
}))
.ok();
STATUS.set(Mutex::new(PassiveStatus::new())).ok();
}
pub fn set_listening() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_listening = true;
s.listening_since = Some(Utc::now());
m.lock().unwrap().set_listening();
}
}
pub fn record_discovery(mac: &str) {
if let Some(m) = STATUS.get() {
let now = Utc::now();
let mut s = m.lock().unwrap();
s.recent_sightings.insert(mac.to_string(), now);
s.last_discovery_at = Some(now);
m.lock().unwrap().record_discovery(mac);
}
}
pub fn get() -> Option<SsdpScannerStatusSnapshot> {
STATUS.get().map(|m| {
let mut s = m.lock().unwrap();
let cutoff = Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS);
s.recent_sightings.retain(|_, seen| *seen >= cutoff);
SsdpScannerStatusSnapshot {
is_listening: s.is_listening,
listening_since: s.listening_since,
devices_last_hour: s.recent_sightings.len() as u64,
last_discovery_at: s.last_discovery_at,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_for_test() {
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.is_listening = false;
s.listening_since = None;
s.recent_sightings.clear();
s.last_discovery_at = None;
} else {
init();
}
}
#[test]
fn test_set_listening() {
reset_for_test();
set_listening();
let snapshot = get().unwrap();
assert!(snapshot.is_listening);
assert!(snapshot.listening_since.is_some());
}
#[test]
fn test_same_mac_counts_once() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
record_discovery("aa:bb:cc:dd:ee:ff");
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 1);
assert!(snapshot.last_discovery_at.is_some());
}
#[test]
fn test_distinct_macs_counted() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
record_discovery("11:22:33:44:55:66");
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 2);
}
#[test]
fn test_old_sighting_excluded() {
reset_for_test();
record_discovery("aa:bb:cc:dd:ee:ff");
// Backdate an entry beyond the window; it must not be counted.
if let Some(m) = STATUS.get() {
let mut s = m.lock().unwrap();
s.recent_sightings.insert(
"11:22:33:44:55:66".to_string(),
Utc::now() - Duration::seconds(RECENT_WINDOW_SECONDS + 60),
);
}
let snapshot = get().unwrap();
assert_eq!(snapshot.devices_last_hour, 1);
}
#[test]
fn test_initial_state() {
reset_for_test();
let snapshot = get().unwrap();
assert!(!snapshot.is_listening);
assert!(snapshot.listening_since.is_none());
assert_eq!(snapshot.devices_last_hour, 0);
assert!(snapshot.last_discovery_at.is_none());
}
pub fn get() -> Option<PassiveSnapshot> {
STATUS.get().map(|m| m.lock().unwrap().snapshot())
}
+17
View File
@@ -155,6 +155,15 @@ pub fn is_locally_administered(mac: &str) -> bool {
.is_some_and(|first_octet| first_octet & 0x02 != 0)
}
/// Format raw MAC bytes as a lowercase colon-separated string (e.g. `0a:00:ff:10:20:30`).
pub fn format_mac(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -288,4 +297,12 @@ mod tests {
assert_eq!(normalize_mac("Aa:Bb:Cc:Dd:Ee:Ff"), "aa:bb:cc:dd:ee:ff");
assert_eq!(normalize_mac(""), "");
}
#[test]
fn test_format_mac_pads_and_lowercases() {
assert_eq!(
format_mac(&[0x0a, 0x00, 0xff, 0x10, 0x20, 0x30]),
"0a:00:ff:10:20:30"
);
}
}
+6 -10
View File
@@ -5,12 +5,10 @@ use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventTy
use crate::model::devices::{Device, DeviceSummary};
use crate::model::notifications::{Notification, NotificationType};
use crate::settings::get_settings;
use crate::web_server::arp_scanner::ArpScannerStatusResponse;
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
use crate::web_server::dhcp_scanner::DhcpScannerStatusResponse;
use crate::web_server::mdns_scanner::MdnsScannerStatusResponse;
use crate::web_server::snmp_scanner::SnmpScannerStatusResponse;
use crate::web_server::ssdp_scanner::SsdpScannerStatusResponse;
use crate::web_server::scanner_status::{
ActiveScannerStatusResponse, PassiveScannerStatusResponse,
};
use axum::Json;
use axum::extract::Request;
use axum::http::StatusCode;
@@ -33,6 +31,7 @@ pub mod devices;
pub mod dhcp_scanner;
pub mod mdns_scanner;
pub mod notifications;
pub mod scanner_status;
pub mod snmp_scanner;
pub mod ssdp_scanner;
pub mod utils;
@@ -74,11 +73,8 @@ pub mod utils;
DeviceEvent,
DeviceEventType,
DeviceEventScanner,
ArpScannerStatusResponse,
MdnsScannerStatusResponse,
SsdpScannerStatusResponse,
DhcpScannerStatusResponse,
SnmpScannerStatusResponse,
ActiveScannerStatusResponse,
PassiveScannerStatusResponse,
)),
modifiers(&SecurityAddon),
tags(
+4 -52
View File
@@ -1,65 +1,17 @@
use axum::{Json, http::StatusCode};
use chrono::Utc;
use log::error;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct ArpScannerStatusResponse {
pub is_running: bool,
/// Seconds the current scan has been running (only set when is_running is true)
pub running_for_seconds: Option<f64>,
/// Seconds until the next scan starts (only set when is_running is false; clamped to 0)
pub next_run_in_seconds: Option<f64>,
/// Number of devices found by the last successful scan (None if none completed yet)
pub last_scan_devices_seen: Option<u64>,
/// Seconds since the last successful scan completed (None if none completed yet)
pub last_scan_seconds_ago: Option<f64>,
}
use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_response};
#[utoipa::path(
get,
path = "/api/arp_scanner/status",
tag = "arp_scanner",
responses(
(status = 200, description = "ARP scanner status", body = ArpScannerStatusResponse),
(status = 200, description = "ARP scanner status", body = ActiveScannerStatusResponse),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn status() -> Result<Json<ArpScannerStatusResponse>, StatusCode> {
let snapshot = match crate::scanners::arp::status::get() {
Some(s) => s,
None => {
error!("ARP scanner status not initialized");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let now = Utc::now();
let (running_for_seconds, next_run_in_seconds) = if snapshot.is_running {
let running_for = snapshot
.scan_started_at
.map(|t| (now - t).num_milliseconds() as f64 / 1000.0);
(running_for, None)
} else {
let next_run_in = snapshot.next_scan_at.map(|t| {
let secs = (t - now).num_milliseconds() as f64 / 1000.0;
secs.max(0.0)
});
(None, next_run_in)
};
let last_scan_seconds_ago = snapshot
.last_scan_at
.map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0));
Ok(Json(ArpScannerStatusResponse {
is_running: snapshot.is_running,
running_for_seconds,
next_run_in_seconds,
last_scan_devices_seen: snapshot.last_scan_devices_seen,
last_scan_seconds_ago,
}))
pub async fn status() -> Result<Json<ActiveScannerStatusResponse>, StatusCode> {
active_response(crate::scanners::arp::status::get()).map(Json)
}
+4 -44
View File
@@ -1,57 +1,17 @@
use axum::{Json, http::StatusCode};
use chrono::Utc;
use log::error;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct DhcpScannerStatusResponse {
pub is_listening: bool,
/// Seconds the listener has been running (only set when is_listening is true)
pub listening_for_seconds: Option<f64>,
/// Distinct devices seen in the last hour
pub devices_seen: u64,
/// Seconds since the last device was seen (None if none seen yet)
pub last_device_seen_seconds_ago: Option<f64>,
}
use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response};
#[utoipa::path(
get,
path = "/api/dhcp_scanner/status",
tag = "dhcp_scanner",
responses(
(status = 200, description = "DHCP scanner status", body = DhcpScannerStatusResponse),
(status = 200, description = "DHCP scanner status", body = PassiveScannerStatusResponse),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn status() -> Result<Json<DhcpScannerStatusResponse>, StatusCode> {
let snapshot = match crate::scanners::dhcp::status::get() {
Some(s) => s,
None => {
error!("DHCP scanner status not initialized");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let now = Utc::now();
let listening_for_seconds = if snapshot.is_listening {
snapshot
.listening_since
.map(|t| (now - t).num_milliseconds() as f64 / 1000.0)
} else {
None
};
let last_device_seen_seconds_ago = snapshot
.last_discovery_at
.map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0));
Ok(Json(DhcpScannerStatusResponse {
is_listening: snapshot.is_listening,
listening_for_seconds,
devices_seen: snapshot.devices_last_hour,
last_device_seen_seconds_ago,
}))
pub async fn status() -> Result<Json<PassiveScannerStatusResponse>, StatusCode> {
passive_response(crate::scanners::dhcp::status::get()).map(Json)
}
+4 -44
View File
@@ -1,57 +1,17 @@
use axum::{Json, http::StatusCode};
use chrono::Utc;
use log::error;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct MdnsScannerStatusResponse {
pub is_listening: bool,
/// Seconds the listener has been running (only set when is_listening is true)
pub listening_for_seconds: Option<f64>,
/// Distinct devices seen in the last hour
pub devices_seen: u64,
/// Seconds since the last device was seen (None if none seen yet)
pub last_device_seen_seconds_ago: Option<f64>,
}
use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response};
#[utoipa::path(
get,
path = "/api/mdns_scanner/status",
tag = "mdns_scanner",
responses(
(status = 200, description = "mDNS scanner status", body = MdnsScannerStatusResponse),
(status = 200, description = "mDNS scanner status", body = PassiveScannerStatusResponse),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn status() -> Result<Json<MdnsScannerStatusResponse>, StatusCode> {
let snapshot = match crate::scanners::mdns::status::get() {
Some(s) => s,
None => {
error!("mDNS scanner status not initialized");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let now = Utc::now();
let listening_for_seconds = if snapshot.is_listening {
snapshot
.listening_since
.map(|t| (now - t).num_milliseconds() as f64 / 1000.0)
} else {
None
};
let last_device_seen_seconds_ago = snapshot
.last_discovery_at
.map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0));
Ok(Json(MdnsScannerStatusResponse {
is_listening: snapshot.is_listening,
listening_for_seconds,
devices_seen: snapshot.devices_last_hour,
last_device_seen_seconds_ago,
}))
pub async fn status() -> Result<Json<PassiveScannerStatusResponse>, StatusCode> {
passive_response(crate::scanners::mdns::status::get()).map(Json)
}
+89
View File
@@ -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)),
})
}
+4 -52
View File
@@ -1,65 +1,17 @@
use axum::{Json, http::StatusCode};
use chrono::Utc;
use log::error;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct SnmpScannerStatusResponse {
pub is_running: bool,
/// Seconds the current poll has been running (only set when is_running is true)
pub running_for_seconds: Option<f64>,
/// Seconds until the next poll starts (only set when is_running is false; clamped to 0)
pub next_run_in_seconds: Option<f64>,
/// Number of devices found by the last successful scan (None if none completed yet)
pub last_scan_devices_seen: Option<u64>,
/// Seconds since the last successful scan completed (None if none completed yet)
pub last_scan_seconds_ago: Option<f64>,
}
use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_response};
#[utoipa::path(
get,
path = "/api/snmp_scanner/status",
tag = "snmp_scanner",
responses(
(status = 200, description = "SNMP scanner status", body = SnmpScannerStatusResponse),
(status = 200, description = "SNMP scanner status", body = ActiveScannerStatusResponse),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn status() -> Result<Json<SnmpScannerStatusResponse>, StatusCode> {
let snapshot = match crate::scanners::snmp::status::get() {
Some(s) => s,
None => {
error!("SNMP scanner status not initialized");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let now = Utc::now();
let (running_for_seconds, next_run_in_seconds) = if snapshot.is_running {
let running_for = snapshot
.scan_started_at
.map(|t| (now - t).num_milliseconds() as f64 / 1000.0);
(running_for, None)
} else {
let next_run_in = snapshot.next_scan_at.map(|t| {
let secs = (t - now).num_milliseconds() as f64 / 1000.0;
secs.max(0.0)
});
(None, next_run_in)
};
let last_scan_seconds_ago = snapshot
.last_scan_at
.map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0));
Ok(Json(SnmpScannerStatusResponse {
is_running: snapshot.is_running,
running_for_seconds,
next_run_in_seconds,
last_scan_devices_seen: snapshot.last_scan_devices_seen,
last_scan_seconds_ago,
}))
pub async fn status() -> Result<Json<ActiveScannerStatusResponse>, StatusCode> {
active_response(crate::scanners::snmp::status::get()).map(Json)
}
+4 -44
View File
@@ -1,57 +1,17 @@
use axum::{Json, http::StatusCode};
use chrono::Utc;
use log::error;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct SsdpScannerStatusResponse {
pub is_listening: bool,
/// Seconds the listener has been running (only set when is_listening is true)
pub listening_for_seconds: Option<f64>,
/// Distinct devices seen in the last hour
pub devices_seen: u64,
/// Seconds since the last device was seen (None if none seen yet)
pub last_device_seen_seconds_ago: Option<f64>,
}
use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_response};
#[utoipa::path(
get,
path = "/api/ssdp_scanner/status",
tag = "ssdp_scanner",
responses(
(status = 200, description = "SSDP scanner status", body = SsdpScannerStatusResponse),
(status = 200, description = "SSDP scanner status", body = PassiveScannerStatusResponse),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn status() -> Result<Json<SsdpScannerStatusResponse>, StatusCode> {
let snapshot = match crate::scanners::ssdp::status::get() {
Some(s) => s,
None => {
error!("SSDP scanner status not initialized");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let now = Utc::now();
let listening_for_seconds = if snapshot.is_listening {
snapshot
.listening_since
.map(|t| (now - t).num_milliseconds() as f64 / 1000.0)
} else {
None
};
let last_device_seen_seconds_ago = snapshot
.last_discovery_at
.map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0));
Ok(Json(SsdpScannerStatusResponse {
is_listening: snapshot.is_listening,
listening_for_seconds,
devices_seen: snapshot.devices_last_hour,
last_device_seen_seconds_ago,
}))
pub async fn status() -> Result<Json<PassiveScannerStatusResponse>, StatusCode> {
passive_response(crate::scanners::ssdp::status::get()).map(Json)
}