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:
rzuasti
2026-06-07 09:10:49 -04:00
co-authored by Claude Opus 4.8
parent 9efe84c099
commit 09e7915547
12 changed files with 1000 additions and 915 deletions
+64 -901
View File
File diff suppressed because it is too large Load Diff
+106
View File
@@ -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
View File
@@ -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
} }
+21
View File
@@ -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::*;
+274
View File
@@ -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"
);
}
}
+81
View File
@@ -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> {
+437
View File
@@ -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"));
}
}
+3 -3
View File
@@ -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();
+8 -6
View File
@@ -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)]
+3 -3
View File
@@ -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),
} }