mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Separate events and notifications into focused modules
The events.rs file mixed three domains: device-event recording, change detection, and the entire notification pipeline (rendering + delivery + sending). This made it long, gave functions side effects beyond their stated goal (classify_* silently recorded events), and intertwined the events and notifications logic. Split along domain boundaries: - model::device_events now owns DeviceChange, the shared contract. - events records device events only (record_new_device/record_known_device); events/detection.rs holds pure change detection. - new notifications module owns rendering, delivery, and sending (notifications.rs + delivery.rs + render.rs); pushover/error moved here. Data now flows one way: events produces DeviceChange, notifications consumes it, both depend only on model. Scanners/pipeline/main orchestrate. classify_* renamed to record_* so the write is the stated goal; send and send_notification collapsed into persist_and_deliver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9efe84c099
commit
09e7915547
+64
-901
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
|
||||||
|
use crate::model::device_events::DeviceChange;
|
||||||
|
use crate::model::devices::Device;
|
||||||
|
use crate::settings::get_settings;
|
||||||
|
|
||||||
|
// Whether a re-sighting represents a real vendor change. A scanner that cannot deduce a vendor
|
||||||
|
// reports an empty string; that is not a change (db::devices::seen keeps the known vendor), so
|
||||||
|
// it must not raise a "vendor changed" notification either. Likewise, first deducing a vendor for a
|
||||||
|
// device that previously had none is not a change worth notifying about.
|
||||||
|
pub fn vendor_changed(existing: &str, new: &str) -> bool {
|
||||||
|
!existing.is_empty() && !new.is_empty() && existing != new
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a re-sighting represents a real IP-address change, mirroring vendor_changed. First
|
||||||
|
// learning an address for a device that previously had none (empty -> value, e.g. a device known
|
||||||
|
// only from a DHCP DISCOVER that later gets an ARP address) is not a change worth recording or
|
||||||
|
// notifying about. A sighting that carries no address (value -> empty) is likewise not a change;
|
||||||
|
// the pipeline already backfills the stored address in that case, so an empty `new` never reaches
|
||||||
|
// here, but the guard keeps this correct independently of the caller.
|
||||||
|
pub fn ip_changed(existing: &str, new: &str) -> bool {
|
||||||
|
!existing.is_empty() && !new.is_empty() && existing != new
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide what notification-worthy changes a known device's re-sighting represents, comparing the
|
||||||
|
/// stored record against the freshly seen one. A return after the configured absence is a
|
||||||
|
/// "back online" change; an IP and/or vendor difference is a "changed" change (both can occur for
|
||||||
|
/// the same sighting). The absence threshold and current time are read from settings here; this
|
||||||
|
/// function performs no database writes and raises no notifications, so the recording layer stays
|
||||||
|
/// in full control of side effects.
|
||||||
|
pub fn detect_known_device_changes(existing: &Device, new: &Device) -> Vec<DeviceChange> {
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
|
||||||
|
let elapsed_since_last_seen: Duration = (Local::now().to_utc() - existing.last_seen)
|
||||||
|
.to_std()
|
||||||
|
.unwrap_or(Duration::from_secs(0));
|
||||||
|
if elapsed_since_last_seen
|
||||||
|
>= Duration::from(get_settings().notifications.notify_when_not_seen_for)
|
||||||
|
{
|
||||||
|
changes.push(DeviceChange::BackOnline {
|
||||||
|
device: new.clone(),
|
||||||
|
absent_for: elapsed_since_last_seen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip_changed_flag = ip_changed(&existing.ipv4_address, &new.ipv4_address);
|
||||||
|
let vendor_changed_flag = vendor_changed(&existing.vendor, &new.vendor);
|
||||||
|
if ip_changed_flag || vendor_changed_flag {
|
||||||
|
changes.push(DeviceChange::Changed {
|
||||||
|
existing: existing.clone(),
|
||||||
|
new: new.clone(),
|
||||||
|
ip_changed: ip_changed_flag,
|
||||||
|
vendor_changed: vendor_changed_flag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
changes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_new_vendor_is_not_a_change() {
|
||||||
|
assert!(!vendor_changed("Apple, Inc.", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_non_empty_vendor_is_a_change() {
|
||||||
|
assert!(vendor_changed("Apple, Inc.", "Google, Inc."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_vendor_is_not_a_change() {
|
||||||
|
assert!(!vendor_changed("Apple, Inc.", "Apple, Inc."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newly_deduced_vendor_from_empty_is_not_a_change() {
|
||||||
|
assert!(!vendor_changed("", "Apple, Inc."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_ip_from_empty_is_not_a_change() {
|
||||||
|
// A device that gains its first address (empty -> value) has not "changed" its IP.
|
||||||
|
assert!(!ip_changed("", "192.168.1.42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ip_to_empty_is_not_a_change() {
|
||||||
|
assert!(!ip_changed("192.168.1.42", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_non_empty_ip_is_a_change() {
|
||||||
|
assert!(ip_changed("192.168.1.42", "192.168.1.99"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_ip_is_not_a_change() {
|
||||||
|
assert!(!ip_changed("192.168.1.42", "192.168.1.42"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -6,6 +6,7 @@ mod data;
|
|||||||
mod db;
|
mod db;
|
||||||
mod events;
|
mod events;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod notifications;
|
||||||
mod retention;
|
mod retention;
|
||||||
mod scanners;
|
mod scanners;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -73,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
scanners::snmp::scanner::scan(),
|
scanners::snmp::scanner::scan(),
|
||||||
web_server::serve(),
|
web_server::serve(),
|
||||||
retention::run(),
|
retention::run(),
|
||||||
events::run_delivery()
|
notifications::run_delivery()
|
||||||
)
|
)
|
||||||
.0
|
.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
use crate::model::devices::Device;
|
||||||
use crate::utils::date_serializer;
|
use crate::utils::date_serializer;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
|
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
use std::{error::Error, fmt, str::FromStr};
|
use std::{error::Error, fmt, str::FromStr};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
@@ -182,6 +184,25 @@ impl FromSql for DeviceEventScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A notification-worthy change detected for one device during a sighting. Produced by the `events`
|
||||||
|
/// module when it records the device event, and consumed by the `notifications` module to render and
|
||||||
|
/// deliver. Sending is deferred from detection so callers can either notify immediately (passive
|
||||||
|
/// listeners, one device per event) or accumulate a whole scan and send one consolidated
|
||||||
|
/// notification per type (active scanners).
|
||||||
|
pub enum DeviceChange {
|
||||||
|
New(Device),
|
||||||
|
BackOnline {
|
||||||
|
device: Device,
|
||||||
|
absent_for: Duration,
|
||||||
|
},
|
||||||
|
Changed {
|
||||||
|
existing: Device,
|
||||||
|
new: Device,
|
||||||
|
ip_changed: bool,
|
||||||
|
vendor_changed: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
mod delivery;
|
||||||
|
mod error;
|
||||||
|
mod pushover;
|
||||||
|
mod render;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use log::{debug, error};
|
||||||
|
|
||||||
|
use crate::db;
|
||||||
|
use crate::model::device_events::DeviceChange;
|
||||||
|
use crate::model::devices::Device;
|
||||||
|
use crate::model::notifications::{Notification, NotificationType};
|
||||||
|
|
||||||
|
pub use delivery::run_delivery;
|
||||||
|
|
||||||
|
/// Send notifications for the changes detected during a scan (or a single sighting). Changes are
|
||||||
|
/// grouped by notification type: a type with exactly one change produces the usual single-device
|
||||||
|
/// notification (carrying its MAC), while a type with two or more produces one consolidated summary
|
||||||
|
/// with an empty MAC. Each notification is persisted and handed to the delivery task.
|
||||||
|
pub fn notify(changes: Vec<DeviceChange>) {
|
||||||
|
let mut new_devices = Vec::new();
|
||||||
|
let mut back_online = Vec::new();
|
||||||
|
let mut changed = Vec::new();
|
||||||
|
|
||||||
|
for change in changes {
|
||||||
|
match change {
|
||||||
|
DeviceChange::New(device) => new_devices.push(device),
|
||||||
|
DeviceChange::BackOnline { device, absent_for } => {
|
||||||
|
back_online.push((device, absent_for))
|
||||||
|
}
|
||||||
|
DeviceChange::Changed {
|
||||||
|
existing,
|
||||||
|
new,
|
||||||
|
ip_changed,
|
||||||
|
vendor_changed,
|
||||||
|
} => changed.push((existing, new, ip_changed, vendor_changed)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_new_devices(new_devices);
|
||||||
|
notify_back_online(back_online);
|
||||||
|
notify_changed(changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notify_new_devices(devices: Vec<Device>) {
|
||||||
|
match devices.as_slice() {
|
||||||
|
[] => {}
|
||||||
|
[device] => {
|
||||||
|
let (title, body) = render::render_new_device(device);
|
||||||
|
persist_and_deliver(
|
||||||
|
NotificationType::NewDeviceFound,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
Some(device.mac_address.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
many => {
|
||||||
|
let refs: Vec<&Device> = many.iter().collect();
|
||||||
|
let (title, body) = render::render_new_devices_summary(&refs);
|
||||||
|
persist_and_deliver(NotificationType::NewDeviceFound, title, body, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notify_back_online(devices: Vec<(Device, Duration)>) {
|
||||||
|
match devices.as_slice() {
|
||||||
|
[] => {}
|
||||||
|
[(device, absent_for)] => {
|
||||||
|
let (title, body) =
|
||||||
|
render::render_device_back_online(device, &render::duration_text(*absent_for));
|
||||||
|
persist_and_deliver(
|
||||||
|
NotificationType::DeviceOnlineAfterTime,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
Some(device.mac_address.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
many => {
|
||||||
|
let refs: Vec<&Device> = many.iter().map(|(device, _)| device).collect();
|
||||||
|
let (title, body) = render::render_back_online_summary(&refs);
|
||||||
|
persist_and_deliver(NotificationType::DeviceOnlineAfterTime, title, body, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notify_changed(devices: Vec<(Device, Device, bool, bool)>) {
|
||||||
|
match devices.as_slice() {
|
||||||
|
[] => {}
|
||||||
|
[(existing, new, ip_changed, vendor_changed)] => {
|
||||||
|
let (title, body) =
|
||||||
|
render::render_device_changed(existing, new, *ip_changed, *vendor_changed);
|
||||||
|
persist_and_deliver(
|
||||||
|
NotificationType::DeviceChanged,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
Some(new.mac_address.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
many => {
|
||||||
|
let refs: Vec<&Device> = many.iter().map(|(_, new, _, _)| new).collect();
|
||||||
|
let (title, body) = render::render_changed_summary(&refs);
|
||||||
|
persist_and_deliver(NotificationType::DeviceChanged, title, body, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a notification, persist it, and hand it to the delivery task. Delivery happens on a separate
|
||||||
|
// task (see `run_delivery`), so this returns as soon as the notification is persisted and never
|
||||||
|
// blocks the caller on the (potentially slow) Pushover HTTP call. A persistence failure is logged
|
||||||
|
// rather than propagated: a scan loop must keep running even if a single notification cannot be
|
||||||
|
// stored, and an unstored notification is not delivered.
|
||||||
|
fn persist_and_deliver(
|
||||||
|
notification_type: NotificationType,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
mac_address: Option<String>,
|
||||||
|
) {
|
||||||
|
let notification = Notification::new(
|
||||||
|
Utc::now(),
|
||||||
|
notification_type,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
true,
|
||||||
|
mac_address,
|
||||||
|
);
|
||||||
|
|
||||||
|
let title = notification.title.clone();
|
||||||
|
let body = notification.body.clone();
|
||||||
|
if let Err(err) = db::notifications::insert(notification) {
|
||||||
|
error!("Failed to record notification: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Queued notification for delivery: {title}");
|
||||||
|
delivery::enqueue(title, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::events;
|
||||||
|
use crate::model::device_events::DeviceEventScanner;
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
|
||||||
|
fn sample_device(name: Option<&str>) -> Device {
|
||||||
|
let mut device = Device::new(
|
||||||
|
"aa:bb:cc:dd:ee:ff".to_string(),
|
||||||
|
"192.168.1.42".to_string(),
|
||||||
|
"Apple, Inc.".to_string(),
|
||||||
|
Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap(),
|
||||||
|
);
|
||||||
|
device.name = name.map(str::to_string);
|
||||||
|
device.device_type = "Smartphone".to_string();
|
||||||
|
device
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_device_change(mac: &str, name: &str) -> DeviceChange {
|
||||||
|
let mut device = sample_device(Some(name));
|
||||||
|
device.mac_address = mac.to_string();
|
||||||
|
DeviceChange::New(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn notifying_one_new_device_persists_a_single_device_notification() {
|
||||||
|
crate::tests_common::setup().await;
|
||||||
|
|
||||||
|
let mac = "fa:ce:fa:ce:00:02".to_string();
|
||||||
|
let mut device = sample_device(Some("printer"));
|
||||||
|
device.mac_address = mac.clone();
|
||||||
|
|
||||||
|
// The delivery loop is not running in tests, so delivery is a no-op; the notification must
|
||||||
|
// still be persisted regardless of whether it is ever delivered.
|
||||||
|
notify(
|
||||||
|
events::record_new_device(device, DeviceEventScanner::Arp)
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let notifications = db::notifications::list(None, None, None).unwrap();
|
||||||
|
assert!(
|
||||||
|
notifications
|
||||||
|
.iter()
|
||||||
|
.any(|n| n.mac_address.as_deref() == Some(mac.as_str())),
|
||||||
|
"A single new-device sighting should persist a notification carrying its MAC"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The test DB is shared across tests, so assertions scope to unique device names rather than
|
||||||
|
// global notification counts.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn notifying_multiple_new_devices_consolidates_into_one_summary() {
|
||||||
|
crate::tests_common::setup().await;
|
||||||
|
|
||||||
|
notify(vec![
|
||||||
|
new_device_change("fa:ce:fa:ce:01:01", "consolidate-printer"),
|
||||||
|
new_device_change("fa:ce:fa:ce:01:02", "consolidate-laptop"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let summaries: Vec<_> = db::notifications::list(None, None, None)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| {
|
||||||
|
n.notification_type == NotificationType::NewDeviceFound
|
||||||
|
&& n.body.contains("consolidate-printer")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
summaries.len(),
|
||||||
|
1,
|
||||||
|
"Two new devices in one scan should produce a single consolidated notification"
|
||||||
|
);
|
||||||
|
let summary = &summaries[0];
|
||||||
|
assert!(
|
||||||
|
summary.mac_address.is_none(),
|
||||||
|
"A multi-device notification must have an empty MAC address"
|
||||||
|
);
|
||||||
|
assert!(summary.title.contains('2'));
|
||||||
|
assert!(summary.body.contains("consolidate-laptop"));
|
||||||
|
// No private data, even in the consolidated body.
|
||||||
|
assert!(!summary.body.contains("fa:ce:fa:ce:01:01"));
|
||||||
|
assert!(!summary.body.contains("fa:ce:fa:ce:01:02"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn notify_groups_changes_by_type() {
|
||||||
|
crate::tests_common::setup().await;
|
||||||
|
|
||||||
|
let mut existing = sample_device(Some("group-server"));
|
||||||
|
existing.mac_address = "fa:ce:fa:ce:02:09".to_string();
|
||||||
|
let mut changed = existing.clone();
|
||||||
|
changed.ipv4_address = "192.168.1.250".to_string();
|
||||||
|
|
||||||
|
// Two new devices (consolidated) plus one changed device (single) in the same scan.
|
||||||
|
notify(vec![
|
||||||
|
new_device_change("fa:ce:fa:ce:02:01", "group-printer"),
|
||||||
|
new_device_change("fa:ce:fa:ce:02:02", "group-laptop"),
|
||||||
|
DeviceChange::Changed {
|
||||||
|
existing,
|
||||||
|
new: changed,
|
||||||
|
ip_changed: true,
|
||||||
|
vendor_changed: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let notifications = db::notifications::list(None, None, None).unwrap();
|
||||||
|
let new_summaries = notifications
|
||||||
|
.iter()
|
||||||
|
.filter(|n| {
|
||||||
|
n.notification_type == NotificationType::NewDeviceFound
|
||||||
|
&& n.body.contains("group-printer")
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let changed_notifications: Vec<_> = notifications
|
||||||
|
.iter()
|
||||||
|
.filter(|n| {
|
||||||
|
n.notification_type == NotificationType::DeviceChanged
|
||||||
|
&& n.mac_address.as_deref() == Some("fa:ce:fa:ce:02:09")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
new_summaries, 1,
|
||||||
|
"Both new devices collapse into one notification"
|
||||||
|
);
|
||||||
|
assert_eq!(changed_notifications.len(), 1);
|
||||||
|
assert!(
|
||||||
|
changed_notifications[0].mac_address.is_some(),
|
||||||
|
"A single changed device keeps its MAC address"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
use log::{error, info, warn};
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::settings::get_settings;
|
||||||
|
|
||||||
|
use super::pushover;
|
||||||
|
|
||||||
|
// A notification handed to the delivery loop. Delivery (a blocking Pushover HTTP call) runs on a
|
||||||
|
// dedicated task so a slow or unreachable Pushover can never stall the scan loops.
|
||||||
|
struct DeliveryRequest {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded so a stuck delivery loop cannot grow memory without limit; on overflow we drop and warn
|
||||||
|
// (delivery is best-effort, matching the "never stop the loop" policy in the scanner pipeline).
|
||||||
|
const DELIVERY_QUEUE_CAPACITY: usize = 100;
|
||||||
|
|
||||||
|
static DELIVERY_TX: OnceCell<mpsc::Sender<DeliveryRequest>> = OnceCell::new();
|
||||||
|
|
||||||
|
/// Owns the receiving end of the notification-delivery channel and delivers notifications off the
|
||||||
|
/// scan loop. Run this as its own task (see `main`); it returns only if the channel is closed.
|
||||||
|
pub async fn run_delivery() {
|
||||||
|
let (tx, mut rx) = mpsc::channel::<DeliveryRequest>(DELIVERY_QUEUE_CAPACITY);
|
||||||
|
if DELIVERY_TX.set(tx).is_err() {
|
||||||
|
error!("Notification delivery loop started more than once; ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(request) = rx.recv().await {
|
||||||
|
deliver(request).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver a single notification according to the configured method. The Pushover call is blocking,
|
||||||
|
// so it runs on the blocking thread pool rather than the delivery task's async thread.
|
||||||
|
async fn deliver(request: DeliveryRequest) {
|
||||||
|
match get_settings().notifications.method.as_str() {
|
||||||
|
"pushover" => match &get_settings().notifications.pushover {
|
||||||
|
Some(config) => {
|
||||||
|
let config = config.clone();
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
pushover::send_message(&config, request.title, request.body)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => error!("Failed to deliver notification via Pushover: {err}"),
|
||||||
|
Err(err) => error!("Notification delivery task panicked: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
error!(
|
||||||
|
"Notification method is 'pushover' but no [notifications.pushover] section is \
|
||||||
|
configured; cannot deliver notification."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
other => {
|
||||||
|
warn!("Notification method set to '{other}'. Set logs to 'info' to see notifications.");
|
||||||
|
info!("Notification: {}", request.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand a notification to the delivery loop. Never blocks: if the loop is not running (e.g. in
|
||||||
|
// tests) or its queue is full, the notification is logged and dropped rather than stalling the
|
||||||
|
// caller (a scan loop).
|
||||||
|
pub(super) fn enqueue(title: String, body: String) {
|
||||||
|
match DELIVERY_TX.get() {
|
||||||
|
Some(tx) => {
|
||||||
|
if let Err(err) = tx.try_send(DeliveryRequest { title, body }) {
|
||||||
|
warn!("Notification delivery queue unavailable; dropping notification: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!("Notification (delivery loop not running): {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use log::{debug, error};
|
|||||||
use pushover::API;
|
use pushover::API;
|
||||||
use pushover::requests::message::SendMessage;
|
use pushover::requests::message::SendMessage;
|
||||||
|
|
||||||
use crate::events::error::DeliveryError;
|
use crate::notifications::error::DeliveryError;
|
||||||
use crate::settings::Pushover;
|
use crate::settings::Pushover;
|
||||||
|
|
||||||
pub fn send_message(config: &Pushover, title: String, body: String) -> Result<(), DeliveryError> {
|
pub fn send_message(config: &Pushover, title: String, body: String) -> Result<(), DeliveryError> {
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
use std::fmt::Write;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use duration_string::DurationString;
|
||||||
|
|
||||||
|
use crate::model::devices::Device;
|
||||||
|
|
||||||
|
// At most this many devices are listed individually in a consolidated summary body; any beyond are
|
||||||
|
// rolled into an "…and N more devices" line so the notification stays short.
|
||||||
|
const SUMMARY_LIST_LIMIT: usize = 3;
|
||||||
|
|
||||||
|
// Placeholder shown in notifications for a value the scanners could not determine. A plain ASCII
|
||||||
|
// hyphen (rather than an em dash) avoids encoding issues across notification transports.
|
||||||
|
const UNKNOWN_PLACEHOLDER: &str = "-";
|
||||||
|
|
||||||
|
// Device name for display in messages; falls back to the placeholder for devices with no
|
||||||
|
// mDNS-discovered hostname (e.g. those found only via ARP).
|
||||||
|
fn display_name(device: &Device) -> &str {
|
||||||
|
device
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or(UNKNOWN_PLACEHOLDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier used in notification titles, so a Pushover preview is triageable
|
||||||
|
// without opening the notification. Prefers the hostname, then the vendor, then a
|
||||||
|
// masked MAC suffix (last two octets only) as a last resort, so no full MAC is ever exposed.
|
||||||
|
fn title_identity(device: &Device) -> String {
|
||||||
|
if let Some(name) = device.name.as_deref().filter(|name| !name.is_empty()) {
|
||||||
|
return name.to_string();
|
||||||
|
}
|
||||||
|
if !device.vendor.is_empty() {
|
||||||
|
return device.vendor.clone();
|
||||||
|
}
|
||||||
|
let mac = &device.mac_address;
|
||||||
|
let suffix = if mac.len() > 5 {
|
||||||
|
&mac[mac.len() - 5..]
|
||||||
|
} else {
|
||||||
|
mac
|
||||||
|
};
|
||||||
|
format!("device …{suffix}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_type_or_placeholder(device: &Device) -> &str {
|
||||||
|
if device.device_type.is_empty() {
|
||||||
|
UNKNOWN_PLACEHOLDER
|
||||||
|
} else {
|
||||||
|
&device.device_type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vendor_or_placeholder(device: &Device) -> &str {
|
||||||
|
if device.vendor.is_empty() {
|
||||||
|
UNKNOWN_PLACEHOLDER
|
||||||
|
} else {
|
||||||
|
&device.vendor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registration_line(device: &Device) -> String {
|
||||||
|
if device.is_registered {
|
||||||
|
if device.owner.is_empty() {
|
||||||
|
"Registered".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Registered to {}", device.owner)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"Not registered".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the duration a device was absent for display (whole seconds, e.g. "12d").
|
||||||
|
pub(super) fn duration_text(absent_for: Duration) -> String {
|
||||||
|
String::from(DurationString::from(Duration::from_secs(
|
||||||
|
absent_for.as_secs(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_new_device(device: &Device) -> (String, String) {
|
||||||
|
let title = format!("New device on your network: {}", title_identity(device));
|
||||||
|
let mut body = String::new();
|
||||||
|
writeln!(
|
||||||
|
body,
|
||||||
|
"A device that has not been seen before joined your network."
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Device").unwrap();
|
||||||
|
writeln!(body, " Name: {}", display_name(device)).unwrap();
|
||||||
|
writeln!(body, " Vendor: {}", vendor_or_placeholder(device)).unwrap();
|
||||||
|
writeln!(body, " Type: {}", device_type_or_placeholder(device)).unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
write!(
|
||||||
|
body,
|
||||||
|
"If you do not recognise this device, consider investigating before \
|
||||||
|
granting it continued access."
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_device_back_online(device: &Device, duration_text: &str) -> (String, String) {
|
||||||
|
let title = format!(
|
||||||
|
"Device back online after {}: {}",
|
||||||
|
duration_text,
|
||||||
|
title_identity(device)
|
||||||
|
);
|
||||||
|
let mut body = String::new();
|
||||||
|
writeln!(
|
||||||
|
body,
|
||||||
|
"A known device returned to your network after being absent."
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Device").unwrap();
|
||||||
|
writeln!(body, " Name: {}", display_name(device)).unwrap();
|
||||||
|
writeln!(body, " Vendor: {}", vendor_or_placeholder(device)).unwrap();
|
||||||
|
writeln!(body, " Type: {}", device_type_or_placeholder(device)).unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Status").unwrap();
|
||||||
|
writeln!(body, " {}", registration_line(device)).unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Activity").unwrap();
|
||||||
|
write!(body, " Absent for: {duration_text}").unwrap();
|
||||||
|
(title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_device_changed(
|
||||||
|
existing: &Device,
|
||||||
|
new: &Device,
|
||||||
|
ip_changed: bool,
|
||||||
|
vendor_changed_flag: bool,
|
||||||
|
) -> (String, String) {
|
||||||
|
let identity = title_identity(new);
|
||||||
|
let title = match (ip_changed, vendor_changed_flag) {
|
||||||
|
(true, true) => format!("Device changed IP and vendor: {identity}"),
|
||||||
|
(true, false) => format!("Device changed IP: {identity}"),
|
||||||
|
(false, true) => format!("Device changed vendor: {identity}"),
|
||||||
|
// Caller guards against calling with both flags false.
|
||||||
|
(false, false) => format!("Device changed: {identity}"),
|
||||||
|
};
|
||||||
|
let mut body = String::new();
|
||||||
|
writeln!(body, "An existing device's network details changed.").unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Device").unwrap();
|
||||||
|
writeln!(body, " Name: {}", display_name(new)).unwrap();
|
||||||
|
writeln!(body, " Type: {}", device_type_or_placeholder(new)).unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Status").unwrap();
|
||||||
|
writeln!(body, " {}", registration_line(new)).unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Changes").unwrap();
|
||||||
|
if ip_changed {
|
||||||
|
// The address values are private and deliberately omitted; the change itself is reported.
|
||||||
|
writeln!(body, " IP address changed").unwrap();
|
||||||
|
}
|
||||||
|
if vendor_changed_flag {
|
||||||
|
writeln!(body, " Vendor: {} -> {}", existing.vendor, new.vendor).unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
write!(
|
||||||
|
body,
|
||||||
|
"A vendor change on the same MAC address is unusual and may indicate \
|
||||||
|
MAC spoofing."
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
(title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One line describing a device in a consolidated summary: its name, plus vendor and type when
|
||||||
|
// known. No MAC or IP address is included.
|
||||||
|
fn summary_device_line(device: &Device) -> String {
|
||||||
|
let mut line = display_name(device).to_string();
|
||||||
|
let mut details = Vec::new();
|
||||||
|
if !device.vendor.is_empty() {
|
||||||
|
details.push(device.vendor.clone());
|
||||||
|
}
|
||||||
|
if !device.device_type.is_empty() {
|
||||||
|
details.push(device.device_type.clone());
|
||||||
|
}
|
||||||
|
if !details.is_empty() {
|
||||||
|
write!(line, " ({})", details.join(", ")).unwrap();
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the capped device list shared by every summary body: up to SUMMARY_LIST_LIMIT devices,
|
||||||
|
// then an "…and N more devices" line when there are more.
|
||||||
|
fn write_device_summary(body: &mut String, devices: &[&Device]) {
|
||||||
|
for device in devices.iter().take(SUMMARY_LIST_LIMIT) {
|
||||||
|
writeln!(body, " - {}", summary_device_line(device)).unwrap();
|
||||||
|
}
|
||||||
|
if devices.len() > SUMMARY_LIST_LIMIT {
|
||||||
|
writeln!(
|
||||||
|
body,
|
||||||
|
" …and {} more devices",
|
||||||
|
devices.len() - SUMMARY_LIST_LIMIT
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_new_devices_summary(devices: &[&Device]) -> (String, String) {
|
||||||
|
let title = format!("{} new devices found on your network", devices.len());
|
||||||
|
let mut body = String::new();
|
||||||
|
writeln!(
|
||||||
|
body,
|
||||||
|
"{} devices that have not been seen before joined your network.",
|
||||||
|
devices.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Devices").unwrap();
|
||||||
|
write_device_summary(&mut body, devices);
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
write!(
|
||||||
|
body,
|
||||||
|
"If you do not recognise these devices, consider investigating before \
|
||||||
|
granting them continued access."
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_back_online_summary(devices: &[&Device]) -> (String, String) {
|
||||||
|
let title = format!("{} devices back online", devices.len());
|
||||||
|
let mut body = String::new();
|
||||||
|
writeln!(
|
||||||
|
body,
|
||||||
|
"{} known devices returned to your network after being absent.",
|
||||||
|
devices.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Devices").unwrap();
|
||||||
|
write_device_summary(&mut body, devices);
|
||||||
|
(title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_changed_summary(devices: &[&Device]) -> (String, String) {
|
||||||
|
let title = format!("{} devices changed on your network", devices.len());
|
||||||
|
let mut body = String::new();
|
||||||
|
writeln!(
|
||||||
|
body,
|
||||||
|
"{} existing devices changed their network details (IP address and/or vendor).",
|
||||||
|
devices.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
writeln!(body, "Devices").unwrap();
|
||||||
|
write_device_summary(&mut body, devices);
|
||||||
|
writeln!(body).unwrap();
|
||||||
|
write!(
|
||||||
|
body,
|
||||||
|
"A vendor change on the same device is unusual and may indicate MAC spoofing."
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
|
||||||
|
fn sample_device(name: Option<&str>) -> Device {
|
||||||
|
let mut device = Device::new(
|
||||||
|
"aa:bb:cc:dd:ee:ff".to_string(),
|
||||||
|
"192.168.1.42".to_string(),
|
||||||
|
"Apple, Inc.".to_string(),
|
||||||
|
Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap(),
|
||||||
|
);
|
||||||
|
device.name = name.map(str::to_string);
|
||||||
|
device.device_type = "Smartphone".to_string();
|
||||||
|
device
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn title_identity_prefers_name_then_vendor_then_masked_mac_suffix() {
|
||||||
|
let mut device = sample_device(Some("bobs-iphone.local"));
|
||||||
|
assert_eq!(title_identity(&device), "bobs-iphone.local");
|
||||||
|
|
||||||
|
device.name = None;
|
||||||
|
assert_eq!(title_identity(&device), "Apple, Inc.");
|
||||||
|
|
||||||
|
// No name and no vendor: fall back to a masked suffix (last two octets only), never the
|
||||||
|
// full MAC.
|
||||||
|
device.vendor = "".to_string();
|
||||||
|
assert_eq!(title_identity(&device), "device …ee:ff");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_line_covers_all_cases() {
|
||||||
|
let mut device = sample_device(None);
|
||||||
|
assert_eq!(registration_line(&device), "Not registered");
|
||||||
|
|
||||||
|
device.is_registered = true;
|
||||||
|
assert_eq!(registration_line(&device), "Registered");
|
||||||
|
|
||||||
|
device.owner = "Alice".to_string();
|
||||||
|
assert_eq!(registration_line(&device), "Registered to Alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_type_falls_back_to_placeholder_when_empty() {
|
||||||
|
let mut device = sample_device(None);
|
||||||
|
assert_eq!(device_type_or_placeholder(&device), "Smartphone");
|
||||||
|
|
||||||
|
device.device_type = "".to_string();
|
||||||
|
assert_eq!(device_type_or_placeholder(&device), UNKNOWN_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vendor_falls_back_to_placeholder_when_empty() {
|
||||||
|
let mut device = sample_device(None);
|
||||||
|
assert_eq!(vendor_or_placeholder(&device), "Apple, Inc.");
|
||||||
|
|
||||||
|
device.vendor = "".to_string();
|
||||||
|
assert_eq!(vendor_or_placeholder(&device), UNKNOWN_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_device_body_uses_placeholder_for_missing_vendor() {
|
||||||
|
let mut device = sample_device(Some("printer.local"));
|
||||||
|
device.vendor = "".to_string();
|
||||||
|
let (_, body) = render_new_device(&device);
|
||||||
|
assert!(body.contains(&format!("Vendor: {UNKNOWN_PLACEHOLDER}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_device_body_includes_fields_and_security_hint_without_private_data() {
|
||||||
|
let device = sample_device(Some("printer.local"));
|
||||||
|
let (title, body) = render_new_device(&device);
|
||||||
|
|
||||||
|
assert_eq!(title, "New device on your network: printer.local");
|
||||||
|
assert!(body.contains("Name: printer.local"));
|
||||||
|
assert!(body.contains("Vendor: Apple, Inc."));
|
||||||
|
assert!(body.contains("Type: Smartphone"));
|
||||||
|
// New devices are never registered, so the status block is omitted entirely.
|
||||||
|
assert!(!body.contains("Status"));
|
||||||
|
assert!(!body.contains("registered"));
|
||||||
|
assert!(body.contains("If you do not recognise this device"));
|
||||||
|
// Private data must never appear in the body.
|
||||||
|
assert!(!body.contains("aa:bb:cc:dd:ee:ff"));
|
||||||
|
assert!(!body.contains("192.168.1.42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_device_body_uses_placeholder_for_missing_name() {
|
||||||
|
let device = sample_device(None);
|
||||||
|
let (_, body) = render_new_device(&device);
|
||||||
|
assert!(body.contains(&format!("Name: {UNKNOWN_PLACEHOLDER}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_back_online_body_includes_duration_and_registration() {
|
||||||
|
let mut device = sample_device(Some("bobs-iphone.local"));
|
||||||
|
device.is_registered = true;
|
||||||
|
device.owner = "Bob".to_string();
|
||||||
|
|
||||||
|
let (title, body) = render_device_back_online(&device, "12d");
|
||||||
|
|
||||||
|
assert_eq!(title, "Device back online after 12d: bobs-iphone.local");
|
||||||
|
assert!(body.contains("Registered to Bob"));
|
||||||
|
assert!(body.contains("Absent for: 12d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_changed_ip_only_shows_ip_row_without_spoofing_hint() {
|
||||||
|
let existing = sample_device(Some("bobs-iphone.local"));
|
||||||
|
let mut new = existing.clone();
|
||||||
|
new.ipv4_address = "192.168.1.99".to_string();
|
||||||
|
|
||||||
|
let (title, body) = render_device_changed(&existing, &new, true, false);
|
||||||
|
|
||||||
|
assert_eq!(title, "Device changed IP: bobs-iphone.local");
|
||||||
|
assert!(body.contains("IP address changed"));
|
||||||
|
assert!(!body.contains("Vendor:"));
|
||||||
|
assert!(!body.contains("MAC spoofing"));
|
||||||
|
// The changed IP values and the MAC are private and must not appear.
|
||||||
|
assert!(!body.contains("192.168.1.42"));
|
||||||
|
assert!(!body.contains("192.168.1.99"));
|
||||||
|
assert!(!body.contains("aa:bb:cc:dd:ee:ff"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_changed_vendor_only_includes_spoofing_hint() {
|
||||||
|
let existing = sample_device(Some("bobs-iphone.local"));
|
||||||
|
let mut new = existing.clone();
|
||||||
|
new.vendor = "Samsung Electronics".to_string();
|
||||||
|
|
||||||
|
let (title, body) = render_device_changed(&existing, &new, false, true);
|
||||||
|
|
||||||
|
assert_eq!(title, "Device changed vendor: bobs-iphone.local");
|
||||||
|
assert!(body.contains("Vendor: Apple, Inc. -> Samsung Electronics"));
|
||||||
|
assert!(!body.contains("IP address: 192."));
|
||||||
|
assert!(body.contains("MAC spoofing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_changed_both_shows_both_rows_and_spoofing_hint() {
|
||||||
|
let existing = sample_device(Some("bobs-iphone.local"));
|
||||||
|
let mut new = existing.clone();
|
||||||
|
new.ipv4_address = "192.168.1.99".to_string();
|
||||||
|
new.vendor = "Samsung Electronics".to_string();
|
||||||
|
|
||||||
|
let (title, body) = render_device_changed(&existing, &new, true, true);
|
||||||
|
|
||||||
|
assert_eq!(title, "Device changed IP and vendor: bobs-iphone.local");
|
||||||
|
assert!(body.contains("IP address changed"));
|
||||||
|
assert!(body.contains("Vendor: Apple, Inc. -> Samsung Electronics"));
|
||||||
|
assert!(body.contains("MAC spoofing"));
|
||||||
|
assert!(!body.contains("192.168.1.42"));
|
||||||
|
assert!(!body.contains("192.168.1.99"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summary_lists_at_most_three_devices_then_counts_the_rest() {
|
||||||
|
let devices: Vec<Device> = (0..5)
|
||||||
|
.map(|i| {
|
||||||
|
let mut device = sample_device(Some(&format!("device-{i}")));
|
||||||
|
device.mac_address = format!("aa:bb:cc:00:00:0{i}");
|
||||||
|
device
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let refs: Vec<&Device> = devices.iter().collect();
|
||||||
|
|
||||||
|
let (_, body) = render_new_devices_summary(&refs);
|
||||||
|
|
||||||
|
assert!(body.contains("device-0"));
|
||||||
|
assert!(body.contains("device-1"));
|
||||||
|
assert!(body.contains("device-2"));
|
||||||
|
assert!(!body.contains("device-3"));
|
||||||
|
assert!(body.contains("…and 2 more devices"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::finder;
|
use super::finder;
|
||||||
use super::status;
|
use super::status;
|
||||||
use crate::events;
|
|
||||||
use crate::model::device_events::DeviceEventScanner;
|
use crate::model::device_events::DeviceEventScanner;
|
||||||
|
use crate::notifications;
|
||||||
use crate::scanners::common::pipeline;
|
use crate::scanners::common::pipeline;
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -25,7 +25,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
status::STATUS.record_scan(&devices);
|
status::STATUS.record_scan(&devices);
|
||||||
|
|
||||||
// Process found devices, accumulating every change so the whole scan emits one
|
// Process found devices, accumulating every change so the whole scan emits one
|
||||||
// consolidated notification per type (see events::notify) rather than one per device.
|
// consolidated notification per type (see notifications::notify) rather than one per device.
|
||||||
let mut changes = Vec::new();
|
let mut changes = Vec::new();
|
||||||
for device in devices.iter() {
|
for device in devices.iter() {
|
||||||
debug!("Online device found {}", device);
|
debug!("Online device found {}", device);
|
||||||
@@ -34,7 +34,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
DeviceEventScanner::Arp,
|
DeviceEventScanner::Arp,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
events::notify(changes);
|
notifications::notify(changes);
|
||||||
|
|
||||||
let wait = Duration::from(get_settings().arp_scanner.wait_between_scans);
|
let wait = Duration::from(get_settings().arp_scanner.wait_between_scans);
|
||||||
let next_scan_at = Utc::now() + chrono::Duration::from_std(wait).unwrap();
|
let next_scan_at = Utc::now() + chrono::Duration::from_std(wait).unwrap();
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
use log::{debug, error};
|
use log::{debug, error};
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::events::{self, DeviceChange};
|
use crate::events;
|
||||||
use crate::model::device_events::DeviceEventScanner;
|
use crate::model::device_events::{DeviceChange, DeviceEventScanner};
|
||||||
use crate::model::devices::Device;
|
use crate::model::devices::Device;
|
||||||
|
use crate::notifications;
|
||||||
|
|
||||||
/// Persist a device sighting and record its device event, then return any notification-worthy
|
/// Persist a device sighting and record its device event, then return any notification-worthy
|
||||||
/// changes it produced (without sending). This feeds every scanner (ARP, mDNS, SSDP, DHCP, SNMP)
|
/// changes it produced (without sending). This feeds every scanner (ARP, mDNS, SSDP, DHCP, SNMP)
|
||||||
/// through one code path. Active scanners accumulate the changes across a whole scan and pass them
|
/// through one code path. Active scanners accumulate the changes across a whole scan and pass them
|
||||||
/// to `events::notify` once, consolidating per type; passive listeners use `record_and_notify`.
|
/// to `notifications::notify` once, consolidating per type; passive listeners use
|
||||||
|
/// `record_and_notify`.
|
||||||
///
|
///
|
||||||
/// When the device is already known, the sighting is reconciled with the stored record:
|
/// 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 previously stored hostname is kept rather than overwritten by this sighting's name;
|
||||||
@@ -37,7 +39,7 @@ pub fn record_sighting(mut device: Device, scanner: DeviceEventScanner) -> Vec<D
|
|||||||
error!("Failed to update device {}: {err}", device.mac_address);
|
error!("Failed to update device {}: {err}", device.mac_address);
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
events::classify_existing_device(recorded, device, scanner)
|
events::record_known_device(recorded, device, scanner)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
debug!("New device {} discovered; inserting", device.mac_address);
|
debug!("New device {} discovered; inserting", device.mac_address);
|
||||||
@@ -45,7 +47,7 @@ pub fn record_sighting(mut device: Device, scanner: DeviceEventScanner) -> Vec<D
|
|||||||
error!("Failed to insert device {}: {err}", device.mac_address);
|
error!("Failed to insert device {}: {err}", device.mac_address);
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
events::classify_new_device(device, scanner)
|
events::record_new_device(device, scanner)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -56,7 +58,7 @@ pub fn record_sighting(mut device: Device, scanner: DeviceEventScanner) -> Vec<D
|
|||||||
/// listeners (mDNS, SSDP, DHCP), which process one device per event and so have nothing to
|
/// listeners (mDNS, SSDP, DHCP), which process one device per event and so have nothing to
|
||||||
/// consolidate across a scan.
|
/// consolidate across a scan.
|
||||||
pub fn record_and_notify(device: Device, scanner: DeviceEventScanner) {
|
pub fn record_and_notify(device: Device, scanner: DeviceEventScanner) {
|
||||||
events::notify(record_sighting(device, scanner));
|
notifications::notify(record_sighting(device, scanner));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::finder;
|
use super::finder;
|
||||||
use super::status;
|
use super::status;
|
||||||
use crate::events;
|
|
||||||
use crate::model::device_events::DeviceEventScanner;
|
use crate::model::device_events::DeviceEventScanner;
|
||||||
|
use crate::notifications;
|
||||||
use crate::scanners::common::pipeline;
|
use crate::scanners::common::pipeline;
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -35,7 +35,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::STATUS.record_scan(&devices);
|
status::STATUS.record_scan(&devices);
|
||||||
// Accumulate every change so the whole poll emits one consolidated notification
|
// Accumulate every change so the whole poll emits one consolidated notification
|
||||||
// per type (see events::notify) rather than one per device.
|
// per type (see notifications::notify) rather than one per device.
|
||||||
let mut changes = Vec::new();
|
let mut changes = Vec::new();
|
||||||
for device in devices.iter() {
|
for device in devices.iter() {
|
||||||
changes.extend(pipeline::record_sighting(
|
changes.extend(pipeline::record_sighting(
|
||||||
@@ -43,7 +43,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
DeviceEventScanner::Snmp,
|
DeviceEventScanner::Snmp,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
events::notify(changes);
|
notifications::notify(changes);
|
||||||
}
|
}
|
||||||
Err(err) => error!("SNMP poll of {} failed: {err}", config.target),
|
Err(err) => error!("SNMP poll of {} failed: {err}", config.target),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user