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
@@ -4,13 +4,16 @@
|
||||
|
||||
## Backend
|
||||
|
||||
- [ ] Add the scanner that triggered the event to the device_events table
|
||||
- [ ] Improve notifications layout/text
|
||||
- [x] Add the scanner that triggered the event to the device_events table
|
||||
- [x] Improve notifications layout/text
|
||||
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
|
||||
- [ ] Add uPNP scanner
|
||||
- [ ] Add DHCP scanner
|
||||
|
||||
## Frontend
|
||||
|
||||
- [ ] In the notifications list, modify the behavior so that you can fully see the text of the notification before navigating to the device details
|
||||
- [ ] Can we add front-end tests?
|
||||
- [x] In the devices list, add a color dot to reflect the last seen status (less than 10 minutes green, otherwise grey)
|
||||
- [x] Change the URI for the homepage from /notifications to /home
|
||||
- [x] In the devices list, the order by name is not consistent (iPad... before Lutron when ordering by name descending, maybe it should be case insensitive)
|
||||
@@ -22,7 +25,6 @@
|
||||
Several complementary approaches work well alongside ARP:
|
||||
|
||||
Passive (low noise, no probing):
|
||||
- mDNS/Bonjour listening — devices broadcast their presence on 224.0.0.251:5353; catches Apple, Android, Chromecast, printers, etc. automatically
|
||||
- SSDP/UPnP — similar but for smart devices/IoT; multicast on 239.255.255.250:1900
|
||||
- DHCP snooping — monitor DHCP DISCOVER/REQUEST packets; new devices must ask for an IP before doing anything else, so this catches them very early
|
||||
- Passive packet capture — observe any broadcast/multicast traffic; a device that never responds to ARP still generates traffic
|
||||
@@ -34,5 +36,3 @@ Active (you probe the network):
|
||||
|
||||
Via infrastructure:
|
||||
- SNMP query to router/switch — pull the router's ARP table or switch MAC table directly; no need to scan at all
|
||||
|
||||
Best bang for the buck: mDNS + DHCP snooping as passive complements to ARP. mDNS is especially good at naming devices (hostname included in the announcement), and DHCP catches devices the moment they connect rather than waiting for an ARP sweep cycle.
|
||||
|
||||
+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