mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Redesign triggered notification titles and bodies
Make each Pushover alert triageable from a lock-screen preview: titles now carry the device's identity (hostname, vendor, or MAC suffix) and a concise verb, and bodies are sectioned (Device / Status / Activity or Changes) with the registration line and device type so a user can decide whether to act without opening the app. Adds a security hint on new-unregistered devices and on vendor-change-on-same-MAC (a MAC-spoofing tell); IP-only changes stay quiet since DHCP rotation is normal. Rendering moves into pure helpers covered by unit tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
55a6b29d1c
commit
3122ab1f0f
+289
-68
@@ -2,6 +2,7 @@ mod error;
|
||||
mod pushover;
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::db;
|
||||
@@ -24,6 +25,45 @@ fn display_name(device: &Device) -> &str {
|
||||
.unwrap_or("(unknown)")
|
||||
}
|
||||
|
||||
// Identifier used in notification titles, so a Pushover preview is triageable
|
||||
// without opening the notification. Prefers the hostname, then the vendor, then a
|
||||
// short MAC suffix as a last resort.
|
||||
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() > 8 {
|
||||
&mac[mac.len() - 8..]
|
||||
} else {
|
||||
mac
|
||||
};
|
||||
format!("MAC ...{suffix}")
|
||||
}
|
||||
|
||||
fn device_type_or_unknown(device: &Device) -> &str {
|
||||
if device.device_type.is_empty() {
|
||||
"Unknown"
|
||||
} else {
|
||||
&device.device_type
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -32,6 +72,111 @@ fn vendor_changed(existing: &str, new: &str) -> bool {
|
||||
!existing.is_empty() && !new.is_empty() && existing != new
|
||||
}
|
||||
|
||||
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, " MAC address: {}", device.mac_address).unwrap();
|
||||
writeln!(body, " IP address: {}", device.ipv4_address).unwrap();
|
||||
writeln!(body, " Vendor: {}", device.vendor).unwrap();
|
||||
writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap();
|
||||
writeln!(body).unwrap();
|
||||
writeln!(body, "Status").unwrap();
|
||||
writeln!(body, " {}", registration_line(device)).unwrap();
|
||||
writeln!(body).unwrap();
|
||||
write!(
|
||||
body,
|
||||
"If you do not recognise this device, consider investigating before \
|
||||
granting it continued access."
|
||||
)
|
||||
.unwrap();
|
||||
(title, body)
|
||||
}
|
||||
|
||||
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, " MAC address: {}", device.mac_address).unwrap();
|
||||
writeln!(body, " IP address: {}", device.ipv4_address).unwrap();
|
||||
writeln!(body, " Vendor: {}", device.vendor).unwrap();
|
||||
writeln!(body, " Type: {}", device_type_or_unknown(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)
|
||||
}
|
||||
|
||||
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, " MAC address: {} (unchanged)", new.mac_address).unwrap();
|
||||
writeln!(body, " Type: {}", device_type_or_unknown(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 {
|
||||
writeln!(
|
||||
body,
|
||||
" IP address: {} -> {}",
|
||||
existing.ipv4_address, new.ipv4_address
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if vendor_changed_flag {
|
||||
writeln!(body, " Vendor: {} -> {}", existing.vendor, new.vendor).unwrap();
|
||||
}
|
||||
if vendor_changed_flag {
|
||||
writeln!(body).unwrap();
|
||||
write!(
|
||||
body,
|
||||
"A vendor change on the same MAC address is unusual and may indicate \
|
||||
MAC spoofing."
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
(title, body)
|
||||
}
|
||||
|
||||
// Private helper function to deliver messages
|
||||
fn send_notification(notification: Notification) -> Result<(), Box<dyn Error>> {
|
||||
debug!("About to record notification in database");
|
||||
@@ -71,17 +216,12 @@ pub fn trigger_new_device(
|
||||
);
|
||||
}
|
||||
|
||||
let (title, body) = render_new_device(&device);
|
||||
let notification = Notification::new(
|
||||
Utc::now(),
|
||||
NotificationType::NewDeviceFound,
|
||||
"New device found in your network".to_string(),
|
||||
format!(
|
||||
"A new device was found in your network:\n\nName: {}\nMAC address: {}\nIP address: {}\nVendor: {}",
|
||||
display_name(&device),
|
||||
device.mac_address,
|
||||
device.ipv4_address,
|
||||
device.vendor
|
||||
),
|
||||
title,
|
||||
body,
|
||||
true,
|
||||
Some(device.mac_address.clone()),
|
||||
);
|
||||
@@ -118,20 +258,15 @@ pub fn trigger_existing_device(
|
||||
if elapsed_since_last_seen
|
||||
>= Duration::from(get_settings().notifications.notify_when_not_seen_for)
|
||||
{
|
||||
let duration_text = String::from(DurationString::from(Duration::from_secs(
|
||||
elapsed_since_last_seen.as_secs(),
|
||||
)));
|
||||
let (title, body) = render_device_back_online(&new_device, &duration_text);
|
||||
let notification = Notification::new(
|
||||
Utc::now(),
|
||||
NotificationType::DeviceOnlineAfterTime,
|
||||
"A device came back online after a while".to_string(),
|
||||
format!(
|
||||
"Device:\n\nName: {}\nMAC address: {}\nIP address: {}\nVendor: {}\n\ncame back online after {}.",
|
||||
display_name(&new_device),
|
||||
new_device.mac_address,
|
||||
new_device.ipv4_address,
|
||||
new_device.vendor,
|
||||
String::from(DurationString::from(Duration::from_secs(
|
||||
elapsed_since_last_seen.as_secs()
|
||||
)))
|
||||
),
|
||||
title,
|
||||
body,
|
||||
true,
|
||||
Some(new_device.mac_address.clone()),
|
||||
);
|
||||
@@ -141,64 +276,22 @@ pub fn trigger_existing_device(
|
||||
|
||||
// Notify if the devices vendor and/or IP changed
|
||||
let ip_changed = existing_device.ipv4_address != new_device.ipv4_address;
|
||||
let vendor_changed = vendor_changed(&existing_device.vendor, &new_device.vendor);
|
||||
let vendor_changed_flag = vendor_changed(&existing_device.vendor, &new_device.vendor);
|
||||
|
||||
if ip_changed && vendor_changed {
|
||||
if ip_changed || vendor_changed_flag {
|
||||
let (title, body) =
|
||||
render_device_changed(&existing_device, &new_device, ip_changed, vendor_changed_flag);
|
||||
let notification = Notification::new(
|
||||
Utc::now(),
|
||||
NotificationType::DeviceChanged,
|
||||
"Device changed vendor and IP address".to_string(),
|
||||
format!(
|
||||
"Device {} (MAC address {}) has changed:\nIP address from {} to {}\nVendor from {} to {}.",
|
||||
display_name(&new_device),
|
||||
existing_device.mac_address,
|
||||
existing_device.ipv4_address,
|
||||
new_device.ipv4_address,
|
||||
existing_device.vendor,
|
||||
new_device.vendor,
|
||||
),
|
||||
title,
|
||||
body,
|
||||
true,
|
||||
Some(new_device.mac_address.clone()),
|
||||
);
|
||||
|
||||
send_notification(notification)?;
|
||||
} else if ip_changed {
|
||||
let notification = Notification::new(
|
||||
Utc::now(),
|
||||
NotificationType::DeviceChanged,
|
||||
"Device changed IP address".to_string(),
|
||||
format!(
|
||||
"Device {} (MAC address {}, {}) has changed:\nIP address from {} to {}.",
|
||||
display_name(&new_device),
|
||||
existing_device.mac_address,
|
||||
existing_device.vendor,
|
||||
existing_device.ipv4_address,
|
||||
new_device.ipv4_address,
|
||||
),
|
||||
true,
|
||||
Some(new_device.mac_address.clone()),
|
||||
);
|
||||
|
||||
send_notification(notification)?;
|
||||
} else if vendor_changed {
|
||||
let notification = Notification::new(
|
||||
Utc::now(),
|
||||
NotificationType::DeviceChanged,
|
||||
"Device changed vendor".to_string(),
|
||||
format!(
|
||||
"Device {} (MAC address {}, {}) has changed:\nVendor from {} to {}.",
|
||||
display_name(&new_device),
|
||||
existing_device.mac_address,
|
||||
existing_device.ipv4_address,
|
||||
existing_device.vendor,
|
||||
new_device.vendor,
|
||||
),
|
||||
true,
|
||||
Some(new_device.mac_address.clone()),
|
||||
);
|
||||
|
||||
send_notification(notification)?;
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -206,6 +299,19 @@ pub fn trigger_existing_device(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
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 empty_new_vendor_is_not_a_change() {
|
||||
@@ -226,4 +332,119 @@ mod tests {
|
||||
fn newly_deduced_vendor_from_empty_is_not_a_change() {
|
||||
assert!(!vendor_changed("", "Apple, Inc."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_identity_prefers_name_then_vendor_then_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.");
|
||||
|
||||
device.vendor = "".to_string();
|
||||
assert_eq!(title_identity(&device), "MAC ...dd: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_unknown_when_empty() {
|
||||
let mut device = sample_device(None);
|
||||
assert_eq!(device_type_or_unknown(&device), "Smartphone");
|
||||
|
||||
device.device_type = "".to_string();
|
||||
assert_eq!(device_type_or_unknown(&device), "Unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_device_body_includes_all_fields_and_security_hint() {
|
||||
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("MAC address: aa:bb:cc:dd:ee:ff"));
|
||||
assert!(body.contains("IP address: 192.168.1.42"));
|
||||
assert!(body.contains("Vendor: Apple, Inc."));
|
||||
assert!(body.contains("Type: Smartphone"));
|
||||
assert!(body.contains("Not registered"));
|
||||
assert!(body.contains("If you do not recognise this device"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_device_body_uses_unknown_for_missing_name() {
|
||||
let device = sample_device(None);
|
||||
let (_, body) = render_new_device(&device);
|
||||
assert!(body.contains("Name: (unknown)"));
|
||||
}
|
||||
|
||||
#[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: 192.168.1.42 -> 192.168.1.99"));
|
||||
assert!(!body.contains("Vendor:"));
|
||||
assert!(!body.contains("MAC spoofing"));
|
||||
assert!(body.contains("MAC address: aa:bb:cc:dd:ee:ff (unchanged)"));
|
||||
}
|
||||
|
||||
#[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: 192.168.1.42 -> 192.168.1.99"));
|
||||
assert!(body.contains("Vendor: Apple, Inc. -> Samsung Electronics"));
|
||||
assert!(body.contains("MAC spoofing"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user