diff --git a/CLAUDE.md b/CLAUDE.md index 6d721a0..84b6b97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ For Flutter/Dart code: ## Project commands +- `cd backend && ./build.sh` from the `backend/` folder - Build the backend - `cd backend && ./run.sh` from the `backend/` folder - Run the backend - `cd frontend && ./run.sh` from the `frontend/` folder - Run the front-end for the web - `cd backend && ./run_tests.sh` - Run the backend tests diff --git a/TODO.md b/TODO.md index 42a1fdf..13c059e 100644 --- a/TODO.md +++ b/TODO.md @@ -10,9 +10,15 @@ - [x] One event for each device appearance - [x] Add date filter to device event list method (from) - [x] Add a data set (json) to store maps from vendors -> device type; modify the device creation so that it uses it automatically +- [x] Add an mDNS/Bonjour scanner +- [ ] Extract the select_interface method and interface logic from ARP scanner to a centralized utility file +- [ ] Figure out if we can univocally identify devices that mask their MAC address (like apple) +- [ ] Add the scanner that triggered the event to the device_events table ## Frontend +- [ ] Add pagination to the devices list screen +- [ ] Add the mDNS/Bonjour scanner status to the Status and Home screens - [x] Change the notifications list so it has explicit paging (not infinite paging) - [x] List recorded devices - [x] Register a device diff --git a/backend/Cargo.lock b/backend/Cargo.lock index bbb4b6a..2947b83 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -71,7 +71,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -82,7 +82,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -216,9 +216,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "2c61cd05405eb1d0f3a4660f802bad76ece84b6e722426342ba5dd511f724e97" dependencies = [ "serde_core", ] @@ -610,7 +610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1473,7 +1473,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1596,6 +1596,8 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "simple-dns", + "socket2 0.6.4", "tokio 1.49.0", "tower", "tower-http", @@ -1609,7 +1611,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.0", "cfg-if 1.0.4", "foreign-types", "libc", @@ -2042,7 +2044,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.0", ] [[package]] @@ -2116,7 +2118,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.0", "once_cell", "serde", "serde_derive", @@ -2140,7 +2142,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.0", "chrono", "fallible-iterator", "fallible-streaming-iterator", @@ -2226,11 +2228,11 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2260,7 +2262,7 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2290,7 +2292,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.0", "core-foundation", "core-foundation-sys", "libc", @@ -2448,6 +2450,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.12.0", +] + [[package]] name = "slab" version = "0.4.11" @@ -2482,12 +2493,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -2552,7 +2563,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2648,9 +2659,9 @@ dependencies = [ "parking_lot 0.12.5", "pin-project-lite 0.2.16", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.4", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2942,7 +2953,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.0", "bytes 1.11.0", "futures-core", "futures-util", @@ -3280,7 +3291,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.0", "hashbrown 0.15.5", "indexmap 2.13.0", "semver 1.0.27", @@ -3330,7 +3341,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -3398,15 +3409,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -3416,71 +3418,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.14" @@ -3557,7 +3494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.12.0", "indexmap 2.13.0", "log", "serde", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 515c79e..70e0f2e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -28,3 +28,5 @@ once_cell = "1.21.3" tower = "0.5.3" utoipa = { version = "5", features = ["axum_extras", "chrono"] } utoipa-swagger-ui = { version = "9", features = ["axum"] } +simple-dns = "0.11.3" +socket2 = "0.6.4" diff --git a/backend/build.sh b/backend/build.sh new file mode 100755 index 0000000..a63ef8a --- /dev/null +++ b/backend/build.sh @@ -0,0 +1,2 @@ +#!/bin/sh +sudo CARGO_HOME=$HOME/.cargo cargo build diff --git a/backend/database_migrations/08-add_name_to_devices/up.sql b/backend/database_migrations/08-add_name_to_devices/up.sql new file mode 100644 index 0000000..a351d3a --- /dev/null +++ b/backend/database_migrations/08-add_name_to_devices/up.sql @@ -0,0 +1 @@ +ALTER TABLE devices ADD COLUMN name TEXT; diff --git a/backend/src/db/device_events.rs b/backend/src/db/device_events.rs index 9b708b4..b51fd9d 100644 --- a/backend/src/db/device_events.rs +++ b/backend/src/db/device_events.rs @@ -159,7 +159,10 @@ mod tests { )) .unwrap(); - assert!(inserted_id >= 0, "Inserted device event id should be positive"); + assert!( + inserted_id >= 0, + "Inserted device event id should be positive" + ); let event = read(inserted_id).unwrap(); assert_eq!(event.mac_address, "aa:aa:aa:aa:aa:aa"); @@ -252,9 +255,18 @@ mod tests { let cutoff = Utc::now() - chrono::TimeDelta::days(365); let purged = purge_older_than(cutoff).unwrap(); - assert!(purged >= 1, "At least 1 device event should have been purged"); - assert!(read(old_id).is_none(), "Old device event should have been purged"); - assert!(read(recent_id).is_some(), "Recent device event should not have been purged"); + assert!( + purged >= 1, + "At least 1 device event should have been purged" + ); + assert!( + read(old_id).is_none(), + "Old device event should have been purged" + ); + assert!( + read(recent_id).is_some(), + "Recent device event should not have been purged" + ); } #[tokio::test] diff --git a/backend/src/db/devices.rs b/backend/src/db/devices.rs index 1a9232e..e8d7298 100644 --- a/backend/src/db/devices.rs +++ b/backend/src/db/devices.rs @@ -19,7 +19,7 @@ pub fn list_devices( let conn = db::get_db_connection(); // Prepare SQL and parameters - let mut sql_statement = "SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type FROM devices WHERE 1=1 ".to_string(); + let mut sql_statement = "SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name FROM devices WHERE 1=1 ".to_string(); let mut params: Vec = Vec::new(); if let Some(is_registered) = is_registered { debug!("Adding filter is_registered={}", is_registered); @@ -64,6 +64,7 @@ pub fn list_devices( is_registered: row.get(4)?, owner: row.get(5)?, device_type: row.get(6)?, + name: row.get(7)?, }) })? .collect::>()?; @@ -76,7 +77,7 @@ pub fn read(mac_address: String) -> Option { let conn = db::get_db_connection(); let result: Result = conn.query_one( - "SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type FROM devices WHERE mac_address=?1", + "SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name FROM devices WHERE mac_address=?1", params![mac_address], |row| { Ok(Device { @@ -87,6 +88,7 @@ pub fn read(mac_address: String) -> Option { is_registered: row.get(4)?, owner: row.get(5)?, device_type: row.get(6)?, + name: row.get(7)?, }) }, ); @@ -109,8 +111,8 @@ pub fn insert(device: Device) -> Result<(), DbError> { let conn = db::get_db_connection(); match conn.execute( - "INSERT INTO devices (mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![device.mac_address, device.ipv4_address, device.vendor, device.last_seen.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false), device.is_registered, device.owner, device.device_type]) { + "INSERT INTO devices (mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![device.mac_address, device.ipv4_address, device.vendor, device.last_seen.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false), device.is_registered, device.owner, device.device_type, device.name]) { Ok(_) => { debug!("Device inserted into database: {}", device); Ok(()) @@ -165,18 +167,31 @@ pub fn get_summary() -> Result { pub fn update(device: Device) -> Result<(), DbError> { let conn = db::get_db_connection(); - match conn.execute( - "UPDATE devices SET ipv4_address=?1, vendor=?2, last_seen=?3, is_registered=?4, owner=?5, device_type=?6 WHERE mac_address=?7", - params![ - device.ipv4_address, - device.vendor, - device.last_seen.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false), - device.is_registered, - device.owner, - device.device_type, - device.mac_address, - ], - ) { + + let mut sql = "UPDATE devices SET ipv4_address=?, vendor=?, last_seen=?, is_registered=?, owner=?, device_type=?".to_string(); + let mut params: Vec = vec![ + device.ipv4_address.clone().into(), + device.vendor.clone().into(), + device + .last_seen + .to_rfc3339_opts(chrono::SecondsFormat::Nanos, false) + .into(), + device.is_registered.into(), + device.owner.clone().into(), + device.device_type.clone().into(), + ]; + + // Only write the name column when it is set, so an ARP rescan (name: None) never + // clobbers a hostname previously stored by the mDNS scanner. + if let Some(name) = &device.name { + sql.push_str(", name=?"); + params.push(name.clone().into()); + } + + sql.push_str(" WHERE mac_address=?"); + params.push(device.mac_address.clone().into()); + + match conn.execute(sql.as_str(), params_from_iter(params.iter())) { Ok(_) => { debug!("Device updated in database: {}", device); Ok(()) @@ -370,6 +385,50 @@ mod tests { ) } + #[tokio::test] + async fn test_update_name_set_and_preserve() { + tests_common::setup().await; + + let last_seen = Utc::now(); + insert(Device::new( + "nn:nn:nn:nn:nn:01".to_string(), + "192.168.210.1".to_string(), + "Test vendor".to_string(), + last_seen, + )) + .unwrap(); + + // ARP-style insert leaves name unset + assert_eq!(read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name, None); + + // mDNS sets the name + let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap(); + device.name = Some("host.local".to_string()); + update(device).unwrap(); + assert_eq!( + read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name, + Some("host.local".to_string()) + ); + + // An ARP rescan (name: None) must NOT clobber the stored name + let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap(); + device.name = None; + device.ipv4_address = "192.168.210.99".to_string(); + update(device).unwrap(); + let device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap(); + assert_eq!(device.name, Some("host.local".to_string())); + assert_eq!(device.ipv4_address, "192.168.210.99".to_string()); + + // A later mDNS sighting updates the name + let mut device = read("nn:nn:nn:nn:nn:01".to_string()).unwrap(); + device.name = Some("renamed.local".to_string()); + update(device).unwrap(); + assert_eq!( + read("nn:nn:nn:nn:nn:01".to_string()).unwrap().name, + Some("renamed.local".to_string()) + ); + } + #[tokio::test] async fn test_insert() { tests_common::setup().await; @@ -406,6 +465,7 @@ mod tests { last_seen: last_seen, owner: "Carl".to_string(), vendor: "Vendor X".to_string(), + name: None, }) .unwrap(); @@ -486,6 +546,7 @@ mod tests { is_registered: true, owner: "Test".to_string(), device_type: "Server".to_string(), + name: None, }) .unwrap(); @@ -498,6 +559,7 @@ mod tests { is_registered: false, owner: "".to_string(), device_type: "".to_string(), + name: None, }) .unwrap(); diff --git a/backend/src/db/notifications.rs b/backend/src/db/notifications.rs index 1a84af1..af85f45 100644 --- a/backend/src/db/notifications.rs +++ b/backend/src/db/notifications.rs @@ -34,8 +34,7 @@ pub fn list( if let (Some(page_offset), Some(page_limit)) = (page_offset, page_limit) { debug!( "Adding paging to list with offset={} and limit={}", - page_offset, - page_limit + page_offset, page_limit ); sql_statement.push_str(" LIMIT ? OFFSET ?"); @@ -211,9 +210,18 @@ mod tests { let cutoff = Utc::now() - chrono::TimeDelta::days(365); let purged = purge_older_than(cutoff).unwrap(); - assert!(purged >= 1, "At least 1 notification should have been purged"); - assert!(read(old_id).is_none(), "Old notification should have been purged"); - assert!(read(recent_id).is_some(), "Recent notification should not have been purged"); + assert!( + purged >= 1, + "At least 1 notification should have been purged" + ); + assert!( + read(old_id).is_none(), + "Old notification should have been purged" + ); + assert!( + read(recent_id).is_some(), + "Recent notification should not have been purged" + ); } #[tokio::test] diff --git a/backend/src/device_finders.rs b/backend/src/device_finders.rs index b2acbe0..3806204 100644 --- a/backend/src/device_finders.rs +++ b/backend/src/device_finders.rs @@ -1,2 +1,4 @@ pub mod arp; pub mod error; +pub mod mac_resolver; +pub mod mdns; diff --git a/backend/src/device_finders/arp.rs b/backend/src/device_finders/arp.rs index 98e5736..62942a3 100644 --- a/backend/src/device_finders/arp.rs +++ b/backend/src/device_finders/arp.rs @@ -14,7 +14,7 @@ use pnet::{ }; use tokio::time::{Duration, timeout}; -fn select_interface<'a>( +pub(crate) fn select_interface<'a>( interfaces: &'a [NetworkInterface], configured: &Option, ) -> Option<&'a NetworkInterface> { @@ -30,7 +30,10 @@ fn select_interface<'a>( } pub async fn find(interface: Option) -> Result, Box> { - debug!("Looking up devices via ARP, configured interface: {:?}", interface); + debug!( + "Looking up devices via ARP, configured interface: {:?}", + interface + ); // Get the network device to use let all_interfaces = datalink::interfaces(); @@ -60,7 +63,10 @@ pub async fn find(interface: Option) -> Result, Box value, None => { - error!("No IP address found for selected interface ({}).", network_interface.name); + error!( + "No IP address found for selected interface ({}).", + network_interface.name + ); return Err(NoIPAddressError.into()); } }; @@ -69,7 +75,10 @@ pub async fn find(interface: Option) -> Result, Box mac, None => { - error!("Could not get MAC address for selected interface ({}).", network_interface.name); + error!( + "Could not get MAC address for selected interface ({}).", + network_interface.name + ); return Err(NoMACAddressError.into()); } }; diff --git a/backend/src/device_finders/arp/packet_send_receive.rs b/backend/src/device_finders/arp/packet_send_receive.rs index 1b726d9..fce13d2 100644 --- a/backend/src/device_finders/arp/packet_send_receive.rs +++ b/backend/src/device_finders/arp/packet_send_receive.rs @@ -1,6 +1,6 @@ use crate::mac_vendor_finder; -use crate::vendor_device_type_finder; use crate::model::devices::Device; +use crate::vendor_device_type_finder; use chrono::Local; use duration_string::DurationString; use log::{debug, info, trace}; diff --git a/backend/src/device_finders/mac_resolver.rs b/backend/src/device_finders/mac_resolver.rs new file mode 100644 index 0000000..ff96992 --- /dev/null +++ b/backend/src/device_finders/mac_resolver.rs @@ -0,0 +1,148 @@ +use std::net::Ipv4Addr; +use std::time::{Duration, Instant}; + +use log::debug; +use pnet::datalink::{self, Channel, Config}; +use pnet::ipnetwork::IpNetwork; +use pnet::packet::arp::{ArpHardwareTypes, ArpOperations, ArpPacket, MutableArpPacket}; +use pnet::packet::ethernet::{EtherTypes, EthernetPacket, MutableEthernetPacket}; +use pnet::packet::{MutablePacket, Packet}; +use pnet::util::MacAddr; + +use crate::device_finders::arp::select_interface; + +const PROC_NET_ARP: &str = "/proc/net/arp"; + +/// Resolve the MAC address for an IPv4 address. +/// +/// Tries the OS neighbor cache (`/proc/net/arp`) first to stay passive; if the IP is not +/// present there, falls back to a single targeted ARP probe on the given interface. Returns +/// `None` if neither method resolves the address. +pub async fn resolve( + ip: Ipv4Addr, + interface: Option, + probe_timeout: Duration, +) -> Option { + if let Ok(contents) = std::fs::read_to_string(PROC_NET_ARP) + && let Some(mac) = parse_proc_net_arp(&contents, ip) + { + debug!("Resolved {ip} -> {mac} via OS ARP cache"); + return mac.parse::().ok(); + } + + debug!("{ip} not in ARP cache, sending a targeted ARP probe"); + tokio::task::spawn_blocking(move || probe_mac(ip, interface, probe_timeout)) + .await + .ok() + .flatten() +} + +/// Parse the contents of `/proc/net/arp` and return the MAC address (as a string) for the +/// given IP, or `None` if it is absent or its entry is incomplete. +pub fn parse_proc_net_arp(contents: &str, ip: Ipv4Addr) -> Option { + let ip_str = ip.to_string(); + // First line is a header. + for line in contents.lines().skip(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() >= 4 && cols[0] == ip_str { + let flags = cols[2]; + let mac = cols[3]; + // 0x0 flags means an incomplete entry; the all-zero MAC is not usable. + if flags == "0x0" || mac == "00:00:00:00:00:00" { + return None; + } + return Some(mac.to_string()); + } + } + None +} + +/// Send a single ARP request for `target_ip` and wait up to `timeout` for the reply. +/// Blocking; intended to run inside `spawn_blocking`. +fn probe_mac(target_ip: Ipv4Addr, interface: Option, timeout: Duration) -> Option { + let all_interfaces = datalink::interfaces(); + let iface = select_interface(&all_interfaces, &interface)?; + let source_mac = iface.mac?; + let source_ip = iface.ips.iter().find_map(|el| match el { + IpNetwork::V4(v4) => Some(v4.ip()), + _ => None, + })?; + + let config = Config { + read_timeout: Some(timeout), + ..Default::default() + }; + let (mut tx, mut rx) = match datalink::channel(iface, config) { + Ok(Channel::Ethernet(tx, rx)) => (tx, rx), + _ => return None, + }; + + let mut arp_buf = [0u8; 28]; + let mut arp_packet = MutableArpPacket::new(&mut arp_buf).unwrap(); + arp_packet.set_hardware_type(ArpHardwareTypes::Ethernet); + arp_packet.set_protocol_type(EtherTypes::Ipv4); + arp_packet.set_hw_addr_len(6); + arp_packet.set_proto_addr_len(4); + arp_packet.set_operation(ArpOperations::Request); + arp_packet.set_sender_hw_addr(source_mac); + arp_packet.set_sender_proto_addr(source_ip); + arp_packet.set_target_hw_addr(MacAddr::zero()); + arp_packet.set_target_proto_addr(target_ip); + + let mut eth_buf = [0u8; 42]; + let mut eth_packet = MutableEthernetPacket::new(&mut eth_buf).unwrap(); + eth_packet.set_destination(MacAddr::broadcast()); + eth_packet.set_source(source_mac); + eth_packet.set_ethertype(EtherTypes::Arp); + eth_packet.set_payload(arp_packet.packet_mut()); + + tx.send_to(eth_packet.to_immutable().packet(), Some(iface.clone())); + + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let buffer = match rx.next() { + Ok(buffer) => buffer, + Err(_) => continue, // read timed out; loop re-checks the deadline + }; + let Some(eth) = EthernetPacket::new(buffer) else { + continue; + }; + if eth.get_ethertype() != EtherTypes::Arp { + continue; + } + let Some(arp) = ArpPacket::new(eth.payload()) else { + continue; + }; + if arp.get_operation() == ArpOperations::Reply && arp.get_sender_proto_addr() == target_ip { + return Some(arp.get_sender_hw_addr()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "IP address HW type Flags HW address Mask Device\n\ +192.168.0.10 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0\n\ +192.168.0.11 0x1 0x0 00:00:00:00:00:00 * eth0\n"; + + #[test] + fn test_parse_known_complete_entry() { + let mac = parse_proc_net_arp(SAMPLE, Ipv4Addr::new(192, 168, 0, 10)); + assert_eq!(mac, Some("aa:bb:cc:dd:ee:ff".to_string())); + } + + #[test] + fn test_parse_incomplete_entry_is_none() { + let mac = parse_proc_net_arp(SAMPLE, Ipv4Addr::new(192, 168, 0, 11)); + assert_eq!(mac, None); + } + + #[test] + fn test_parse_absent_ip_is_none() { + let mac = parse_proc_net_arp(SAMPLE, Ipv4Addr::new(10, 0, 0, 1)); + assert_eq!(mac, None); + } +} diff --git a/backend/src/device_finders/mdns.rs b/backend/src/device_finders/mdns.rs new file mode 100644 index 0000000..74f2908 --- /dev/null +++ b/backend/src/device_finders/mdns.rs @@ -0,0 +1,99 @@ +use std::net::{Ipv4Addr, SocketAddrV4}; + +use log::{debug, info}; +use pnet::datalink; +use pnet::ipnetwork::IpNetwork; +use simple_dns::Packet; +use simple_dns::rdata::RData; +use socket2::{Domain, Protocol, Socket, Type}; +use tokio::net::UdpSocket; + +use crate::device_finders::arp::select_interface; + +const MDNS_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); +const MDNS_PORT: u16 = 5353; + +/// Open a UDP socket that passively listens for mDNS multicast announcements on the given +/// interface. The socket is bound with address/port reuse so it coexists with other mDNS +/// responders (e.g. avahi) already using port 5353. +pub fn open_socket(interface: Option) -> Result> { + let all_interfaces = datalink::interfaces(); + let iface = select_interface(&all_interfaces, &interface) + .ok_or("No suitable interface found for the mDNS listener")?; + let iface_ip = iface + .ips + .iter() + .find_map(|el| match el { + IpNetwork::V4(v4) => Some(v4.ip()), + _ => None, + }) + .ok_or("Selected interface has no IPv4 address")?; + + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_reuse_address(true)?; + #[cfg(unix)] + socket.set_reuse_port(true)?; + socket.bind(&SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT).into())?; + socket.join_multicast_v4(&MDNS_GROUP, &iface_ip)?; + socket.set_nonblocking(true)?; + + let udp = UdpSocket::from_std(socket.into())?; + info!( + "mDNS listener bound to port {MDNS_PORT} on interface {} ({iface_ip})", + iface.name + ); + Ok(udp) +} + +/// Parse a raw mDNS/DNS message and return the hostnames advertised by its A records. +pub fn parse_announcement(buf: &[u8]) -> Vec { + let packet = match Packet::parse(buf) { + Ok(p) => p, + Err(err) => { + debug!("Ignoring unparseable mDNS packet: {err}"); + return Vec::new(); + } + }; + + packet + .answers + .iter() + .chain(packet.additional_records.iter()) + .filter(|record| matches!(record.rdata, RData::A(_))) + .map(|record| record.name.to_string()) + .filter(|host| !host.is_empty()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use simple_dns::rdata::A; + use simple_dns::{CLASS, Name, Packet, ResourceRecord}; + + #[test] + fn test_parse_announcement_extracts_hostname() { + let mut packet = Packet::new_reply(1); + let name = Name::new("Test-Device.local").unwrap(); + packet.answers.push(ResourceRecord::new( + name, + CLASS::IN, + 120, + RData::A(A { + address: u32::from(Ipv4Addr::new(192, 168, 1, 5)), + }), + )); + let bytes = packet.build_bytes_vec().unwrap(); + + let hosts = parse_announcement(&bytes); + assert!( + hosts.iter().any(|h| h.contains("Test-Device.local")), + "expected to extract Test-Device.local, got {hosts:?}" + ); + } + + #[test] + fn test_parse_announcement_ignores_garbage() { + assert!(parse_announcement(&[0xff, 0x00, 0x13]).is_empty()); + } +} diff --git a/backend/src/events.rs b/backend/src/events.rs index 52490c0..07eca85 100644 --- a/backend/src/events.rs +++ b/backend/src/events.rs @@ -14,6 +14,16 @@ use chrono::{Local, Utc}; use duration_string::DurationString; use log::{debug, error, info, warn}; +// Device name for display in messages; falls back to "(unknown)" 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)") +} + // Private helper function to deliver messages fn send_notification(notification: Notification) -> Result<(), Box> { debug!("About to record notification in database"); @@ -43,7 +53,10 @@ pub fn trigger_new_device(device: Device) -> Result<(), Box> { device.vendor.clone(), ); if let Err(err) = db::device_events::insert(event) { - error!("Failed to record device event for {}: {}", device.mac_address, err); + error!( + "Failed to record device event for {}: {}", + device.mac_address, err + ); } let notification = Notification::new( @@ -51,8 +64,11 @@ pub fn trigger_new_device(device: Device) -> Result<(), Box> { NotificationType::NewDeviceFound, "New device found in your network".to_string(), format!( - "A new device was found in your network:\n\nMAC address: {}\nIP address: {}\nVendor: {}", - device.mac_address, device.ipv4_address, device.vendor + "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 ), true, Some(device.mac_address.clone()), @@ -74,7 +90,10 @@ pub fn trigger_existing_device( new_device.vendor.clone(), ); if let Err(err) = db::device_events::insert(event) { - error!("Failed to record device event for {}: {}", new_device.mac_address, err); + error!( + "Failed to record device event for {}: {}", + new_device.mac_address, err + ); } // Notify if the device comes back online after not being seen for the configured period @@ -90,7 +109,8 @@ pub fn trigger_existing_device( NotificationType::DeviceOnlineAfterTime, "A device came back online after a while".to_string(), format!( - "Device:\n\nMAC address: {}\nIP address: {}\nVendor: {}\n\ncame back online after {}.", + "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, @@ -114,7 +134,8 @@ pub fn trigger_existing_device( NotificationType::DeviceChanged, "Device changed vendor and IP address".to_string(), format!( - "Device with MAC address {} has changed:\nIP address from {} to {}\nVendor from {} to {}.", + "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, @@ -132,7 +153,8 @@ pub fn trigger_existing_device( NotificationType::DeviceChanged, "Device changed IP address".to_string(), format!( - "Device with MAC address {} ({}) has changed:\nIP address from {} to {}.", + "Device {} (MAC address {}, {}) has changed:\nIP address from {} to {}.", + display_name(&new_device), existing_device.mac_address, existing_device.vendor, existing_device.ipv4_address, @@ -149,7 +171,8 @@ pub fn trigger_existing_device( NotificationType::DeviceChanged, "Device changed vendor".to_string(), format!( - "Device with MAC address {} ({}) has changed:\nVendor from {} to {}.", + "Device {} (MAC address {}, {}) has changed:\nVendor from {} to {}.", + display_name(&new_device), existing_device.mac_address, existing_device.ipv4_address, existing_device.vendor, diff --git a/backend/src/main.rs b/backend/src/main.rs index 02a06fe..d91ce48 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -8,11 +8,13 @@ mod db; mod device_finders; mod events; mod mac_vendor_finder; -mod vendor_device_type_finder; +mod mdns_scanner; +mod mdns_scanner_status; mod model; mod retention; mod settings; mod utils; +mod vendor_device_type_finder; mod web_server; #[cfg(test)] @@ -59,9 +61,16 @@ async fn main() -> Result<(), Box> { // Initialize database db::init_db().await?; - // Initialize ARP scanner status tracking + // Initialize scanner status tracking arp_scanner_status::init(); + mdns_scanner_status::init(); - // Start the device scanner, web server, and retention cleaner in parallel - tokio::join!(arp_scanner::scan(), web_server::serve(), retention::run()).0 + // Start the device scanners, web server, and retention cleaner in parallel + tokio::join!( + arp_scanner::scan(), + mdns_scanner::listen(), + web_server::serve(), + retention::run() + ) + .0 } diff --git a/backend/src/mdns_scanner.rs b/backend/src/mdns_scanner.rs new file mode 100644 index 0000000..e46164f --- /dev/null +++ b/backend/src/mdns_scanner.rs @@ -0,0 +1,95 @@ +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use chrono::Local; +use log::{debug, error, info, warn}; + +use crate::db; +use crate::device_finders::{mac_resolver, mdns}; +use crate::events; +use crate::mac_vendor_finder; +use crate::mdns_scanner_status; +use crate::model::devices::Device; +use crate::settings::get_settings; +use crate::vendor_device_type_finder; + +/// Passively listen for mDNS/Bonjour announcements and feed discovered devices into the same +/// pipeline used by the ARP scanner (devices table + events + notifications). +pub async fn listen() -> Result<(), Box> { + let interface = get_settings().networking.interface.clone(); + let socket = mdns::open_socket(interface.clone())?; + mdns_scanner_status::set_listening(); + info!("mDNS scanner listening for announcements"); + + let probe_timeout = Duration::from(get_settings().mdns_scanner.probe_timeout); + + let mut buf = [0u8; 4096]; + loop { + let (len, src) = match socket.recv_from(&mut buf).await { + Ok(value) => value, + Err(err) => { + warn!("mDNS socket receive error: {err}"); + continue; + } + }; + + let src_ip = match src.ip() { + IpAddr::V4(ip) => ip, + IpAddr::V6(_) => continue, // the device model is IPv4-only + }; + + let hostname = match mdns::parse_announcement(&buf[..len]).into_iter().next() { + Some(host) => host, + None => continue, + }; + + process_announcement(src_ip, hostname, interface.clone(), probe_timeout).await; + } +} + +async fn process_announcement( + src_ip: Ipv4Addr, + hostname: String, + interface: Option, + probe_timeout: Duration, +) { + let mac = match mac_resolver::resolve(src_ip, interface, probe_timeout).await { + Some(mac) => mac.to_string(), + None => { + debug!("Could not resolve MAC for mDNS device {src_ip} ({hostname}); skipping"); + return; + } + }; + + let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string()); + let mut device = Device::new( + mac.clone(), + src_ip.to_string(), + vendor, + Local::now().to_utc(), + ); + device.device_type = vendor_device_type_finder::find(&device.vendor); + device.name = Some(hostname); + + match db::devices::read(mac.clone()) { + Some(recorded) => { + debug!("mDNS sighting of known device {mac}; updating"); + if let Err(err) = db::devices::update(device.clone()) { + error!("Failed to update mDNS device {mac}: {err}"); + return; + } + // Ignoring errors: do not stop the listener if notification delivery fails + events::trigger_existing_device(recorded, device).ok(); + } + None => { + debug!("New device {mac} discovered via mDNS; inserting"); + if let Err(err) = db::devices::insert(device.clone()) { + error!("Failed to insert mDNS device {mac}: {err}"); + return; + } + events::trigger_new_device(device).ok(); + } + } + + mdns_scanner_status::record_discovery(); +} diff --git a/backend/src/mdns_scanner_status.rs b/backend/src/mdns_scanner_status.rs new file mode 100644 index 0000000..393ee21 --- /dev/null +++ b/backend/src/mdns_scanner_status.rs @@ -0,0 +1,105 @@ +use chrono::{DateTime, Utc}; +use once_cell::sync::OnceCell; +use std::sync::Mutex; + +pub struct MdnsScannerStatus { + pub is_listening: bool, + pub listening_since: Option>, + pub devices_discovered: u64, + pub last_discovery_at: Option>, +} + +#[derive(Clone)] +pub struct MdnsScannerStatusSnapshot { + pub is_listening: bool, + pub listening_since: Option>, + pub devices_discovered: u64, + pub last_discovery_at: Option>, +} + +static STATUS: OnceCell> = OnceCell::new(); + +pub fn init() { + STATUS + .set(Mutex::new(MdnsScannerStatus { + is_listening: false, + listening_since: None, + devices_discovered: 0, + last_discovery_at: None, + })) + .ok(); +} + +pub fn set_listening() { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.is_listening = true; + s.listening_since = Some(Utc::now()); + } +} + +pub fn record_discovery() { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.devices_discovered += 1; + s.last_discovery_at = Some(Utc::now()); + } +} + +pub fn get() -> Option { + STATUS.get().map(|m| { + let s = m.lock().unwrap(); + MdnsScannerStatusSnapshot { + is_listening: s.is_listening, + listening_since: s.listening_since, + devices_discovered: s.devices_discovered, + last_discovery_at: s.last_discovery_at, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reset_for_test() { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.is_listening = false; + s.listening_since = None; + s.devices_discovered = 0; + s.last_discovery_at = None; + } else { + init(); + } + } + + #[test] + fn test_set_listening() { + reset_for_test(); + set_listening(); + let snapshot = get().unwrap(); + assert!(snapshot.is_listening); + assert!(snapshot.listening_since.is_some()); + } + + #[test] + fn test_record_discovery() { + reset_for_test(); + record_discovery(); + record_discovery(); + let snapshot = get().unwrap(); + assert_eq!(snapshot.devices_discovered, 2); + assert!(snapshot.last_discovery_at.is_some()); + } + + #[test] + fn test_initial_state() { + reset_for_test(); + let snapshot = get().unwrap(); + assert!(!snapshot.is_listening); + assert!(snapshot.listening_since.is_none()); + assert_eq!(snapshot.devices_discovered, 0); + assert!(snapshot.last_discovery_at.is_none()); + } +} diff --git a/backend/src/model/devices.rs b/backend/src/model/devices.rs index 0c24710..a02def3 100644 --- a/backend/src/model/devices.rs +++ b/backend/src/model/devices.rs @@ -24,6 +24,8 @@ pub struct Device { pub is_registered: bool, pub owner: String, pub device_type: String, + /// Hostname discovered via mDNS/Bonjour. None for devices found only via ARP. + pub name: Option, } impl Device { @@ -41,6 +43,7 @@ impl Device { is_registered: false, owner: "".to_string(), device_type: "".to_string(), + name: None, } } } @@ -49,14 +52,15 @@ impl fmt::Display for Device { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, - "mac={}, ip={}, vendor={}, last_seen={}, is_registered={}, owner={}, device_type={}", + "mac={}, ip={}, vendor={}, last_seen={}, is_registered={}, owner={}, device_type={}, name={}", self.mac_address, self.ipv4_address, self.vendor, self.last_seen, self.is_registered, self.owner, - self.device_type + self.device_type, + self.name.as_deref().unwrap_or("") ) } } diff --git a/backend/src/model/notifications.rs b/backend/src/model/notifications.rs index 5f88d09..db0e3f5 100644 --- a/backend/src/model/notifications.rs +++ b/backend/src/model/notifications.rs @@ -44,7 +44,13 @@ impl fmt::Display for Notification { write!( f, "id={}, created_on={}, notification_type={}, is_new={}, mac_address={:?}\ntitle={}\nbody={}", - self.id, self.created_on, self.notification_type, self.is_new, self.mac_address, self.title, self.body + self.id, + self.created_on, + self.notification_type, + self.is_new, + self.mac_address, + self.title, + self.body ) } } diff --git a/backend/src/settings.rs b/backend/src/settings.rs index 6f56f43..c3a0bff 100644 --- a/backend/src/settings.rs +++ b/backend/src/settings.rs @@ -28,6 +28,19 @@ pub struct ArpScanner { pub scan_duration: DurationString, } +#[derive(Debug, Deserialize, Clone)] +pub struct MdnsScanner { + pub probe_timeout: DurationString, +} + +impl Default for MdnsScanner { + fn default() -> Self { + MdnsScanner { + probe_timeout: DurationString::try_from("2s".to_string()).unwrap(), + } + } +} + #[derive(Debug, Deserialize, Clone)] pub struct Pushover { pub token: String, @@ -71,6 +84,8 @@ pub struct Settings { pub web_server: WebServer, #[serde(default)] pub retention: Retention, + #[serde(default)] + pub mdns_scanner: MdnsScanner, } // End configuration structure // ----------------------------------------------------------- diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index 330b6e5..de5e5cc 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -6,6 +6,8 @@ use crate::model::notifications::{Notification, NotificationType}; use crate::settings::get_settings; use crate::web_server::arp_scanner::ArpScannerStatusResponse; use crate::web_server::devices::RegisterDevicePayload; +use crate::web_server::mdns_scanner::MdnsScannerStatusResponse; +use axum::Json; use axum::extract::Request; use axum::http::StatusCode; use axum::middleware::Next; @@ -16,15 +18,15 @@ use log::{debug, error, info}; use tower::ServiceBuilder; use tower_http::cors::{Any, CorsLayer}; use tower_http::services::ServeDir; -use axum::Json; +use utoipa::Modify; use utoipa::OpenApi; use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; -use utoipa::Modify; use utoipa_swagger_ui::SwaggerUi; pub mod arp_scanner; pub mod device_events; pub mod devices; +pub mod mdns_scanner; pub mod notifications; pub mod utils; @@ -49,6 +51,7 @@ pub mod utils; notifications::mark_all_as_old, device_events::list, arp_scanner::status, + mdns_scanner::status, ), components(schemas( Device, @@ -59,6 +62,7 @@ pub mod utils; DeviceEvent, DeviceEventType, ArpScannerStatusResponse, + MdnsScannerStatusResponse, )), modifiers(&SecurityAddon), tags( @@ -66,6 +70,7 @@ pub mod utils; (name = "notifications", description = "Notification management"), (name = "device_events", description = "Device event history"), (name = "arp_scanner", description = "ARP scanner process status"), + (name = "mdns_scanner", description = "mDNS/Bonjour scanner process status"), ) )] struct ApiDoc; @@ -77,11 +82,7 @@ impl Modify for SecurityAddon { let components = openapi.components.get_or_insert_with(Default::default); components.add_security_scheme( "bearer_auth", - SecurityScheme::Http( - HttpBuilder::new() - .scheme(HttpAuthScheme::Bearer) - .build(), - ), + SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).build()), ); } } @@ -108,8 +109,12 @@ pub async fn serve() -> Result<(), Box> { .route("/api/devices/summary", get(devices::summary)) .route("/api/devices/{mac_address}", delete(devices::unregister)) .route("/api/devices/{mac_address}", get(devices::read)) - .route("/api/devices/{mac_address}/events", get(device_events::list)) + .route( + "/api/devices/{mac_address}/events", + get(device_events::list), + ) .route("/api/arp_scanner/status", get(arp_scanner::status)) + .route("/api/mdns_scanner/status", get(mdns_scanner::status)) .route("/api/notifications", get(notifications::list)) .route( "/api/notifications/mark_all_as_old", diff --git a/backend/src/web_server/devices.rs b/backend/src/web_server/devices.rs index 60818eb..218a6d8 100644 --- a/backend/src/web_server/devices.rs +++ b/backend/src/web_server/devices.rs @@ -8,7 +8,10 @@ use log::{debug, error}; use serde::Deserialize; use utoipa::ToSchema; -use crate::{db, model::devices::{Device, DeviceSummary}}; +use crate::{ + db, + model::devices::{Device, DeviceSummary}, +}; use crate::web_server::utils; diff --git a/backend/src/web_server/mdns_scanner.rs b/backend/src/web_server/mdns_scanner.rs new file mode 100644 index 0000000..4dfc8ef --- /dev/null +++ b/backend/src/web_server/mdns_scanner.rs @@ -0,0 +1,59 @@ +use axum::{Json, http::StatusCode}; +use chrono::Utc; +use log::error; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::mdns_scanner_status; + +#[derive(Serialize, ToSchema)] +pub struct MdnsScannerStatusResponse { + pub is_listening: bool, + /// Seconds the listener has been running (only set when is_listening is true) + pub listening_for_seconds: Option, + /// Total device announcements processed since the listener started + pub devices_seen: u64, + /// Seconds since the last device was seen (None if none seen yet) + pub last_device_seen_seconds_ago: Option, +} + +#[utoipa::path( + get, + path = "/api/mdns_scanner/status", + tag = "mdns_scanner", + responses( + (status = 200, description = "mDNS scanner status", body = MdnsScannerStatusResponse), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn status() -> Result, StatusCode> { + let snapshot = match mdns_scanner_status::get() { + Some(s) => s, + None => { + error!("mDNS scanner status not initialized"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let now = Utc::now(); + + let listening_for_seconds = if snapshot.is_listening { + snapshot + .listening_since + .map(|t| (now - t).num_milliseconds() as f64 / 1000.0) + } else { + None + }; + + let last_device_seen_seconds_ago = snapshot + .last_discovery_at + .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); + + Ok(Json(MdnsScannerStatusResponse { + is_listening: snapshot.is_listening, + listening_for_seconds, + devices_seen: snapshot.devices_discovered, + last_device_seen_seconds_ago, + })) +} diff --git a/examples/sample_oott.toml b/examples/sample_oott.toml index 9275e30..e439b2d 100644 --- a/examples/sample_oott.toml +++ b/examples/sample_oott.toml @@ -8,10 +8,13 @@ path = "./oott.db" # Database path. Make sure it's writeable for the user runnin level = "info" # off, error, warn, info, debug, trace [arp_scanner] -wait_between_scans="10m" # Wait time between scans. This does not include the scan time +wait_between_scans="30m" # Wait time between scans. This does not include the scan time sender_timeout="1m" # If the ARP sender process takes longer than this it will be stopped (for a class C network - 254 IPs - it should take less than a minute) scan_duration="10m" # How long to wait for response packets on each scan (5m to 10m is a good timeframe for a class B or C network) +[mdns_scanner] +probe_timeout="2s" # When an mDNS-discovered IP is not in the OS ARP cache, how long to wait for a targeted ARP probe reply to resolve its MAC address + [notifications] method="pushover" # For now just pushover, you can set this to "none" to avoid sending notifications (it will just log) notify_when_not_seen_for="1w" # Send a notification if a device comes back online after not being seen for this timeframe diff --git a/nix/modules/oott-service.nix b/nix/modules/oott-service.nix index d1131c3..0c53b1e 100644 --- a/nix/modules/oott-service.nix +++ b/nix/modules/oott-service.nix @@ -31,21 +31,26 @@ in { description = "Log level for the oott service"; default = "info"; }; - timings.wait_between_scans = mkOption { + arp_scanner.wait_between_scans = mkOption { type = types.str; description = "Wait time between scans. This does not include the scan time."; - default = "15m"; + default = "30m"; }; - timings.arp_sender_timeout = mkOption { + arp_scanner.sender_timeout = mkOption { type = types.str; description = "If the ARP sender process takes longer than this it will be stopped (for a class C network - 254 IPs - it should take less than a minute)."; default = "1m"; }; - timings.arp_scan_duration = mkOption { + arp_scanner.scan_duration = mkOption { type = types.str; description = "How long to wait for response packets on each scan (5m to 10m is a good timeframe for a class B or C network)."; default = "10m"; }; + mdns_scanner.probe_timeout = mkOption { + type = types.str; + description = "When an mDNS-discovered IP is not in the OS ARP cache, how long to wait for a targeted ARP probe reply to resolve its MAC address."; + default = "2s"; + }; notifications.method = mkOption { type = types.str; description = "For now just pushover, you can set this to none to avoid sending notifications (it will just log).";