mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Add passive mDNS/Bonjour discovery scanner
Introduce a second discovery module that passively listens for mDNS multicast announcements (224.0.0.251:5353) and feeds discovered devices into the existing devices/events/notifications pipeline, running in its own task alongside the ARP scanner. Since mDNS carries an IP and hostname but no MAC (the device key), the module resolves IP->MAC via the OS ARP cache with a targeted ARP-probe fallback. The advertised hostname is stored in a new optional `name` column on devices (blank for ARP-only devices); the single `update` writes `name` only when set so ARP rescans never clobber it. Device names are included in event/notification messages. Adds a GET /api/mdns_scanner/status endpoint (is_listening, devices_seen, last_device_seen_seconds_ago) wired into OpenAPI, an optional [mdns_scanner] config section, and the corresponding Nix module option. Also aligns the Nix module's stale `timings` section with the current `arp_scanner` schema. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
2fe850c070
commit
ca83009944
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+35
-98
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
sudo CARGO_HOME=$HOME/.cargo cargo build
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE devices ADD COLUMN name TEXT;
|
||||
@@ -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]
|
||||
|
||||
+78
-16
@@ -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<rusqlite::types::Value> = 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::<Result<_, _>>()?;
|
||||
@@ -76,7 +77,7 @@ pub fn read(mac_address: String) -> Option<Device> {
|
||||
let conn = db::get_db_connection();
|
||||
|
||||
let result: Result<Device, rusqlite::Error> = 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<Device> {
|
||||
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<DeviceSummary, DbError> {
|
||||
|
||||
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<rusqlite::types::Value> = 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();
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod arp;
|
||||
pub mod error;
|
||||
pub mod mac_resolver;
|
||||
pub mod mdns;
|
||||
|
||||
@@ -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<String>,
|
||||
) -> Option<&'a NetworkInterface> {
|
||||
@@ -30,7 +30,10 @@ fn select_interface<'a>(
|
||||
}
|
||||
|
||||
pub async fn find(interface: Option<String>) -> Result<Vec<Device>, Box<dyn std::error::Error>> {
|
||||
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<String>) -> Result<Vec<Device>, Box<dyn std:
|
||||
{
|
||||
Some(value) => 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<String>) -> Result<Vec<Device>, Box<dyn std:
|
||||
let mac = match network_interface.mac {
|
||||
Some(mac) => 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());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
probe_timeout: Duration,
|
||||
) -> Option<MacAddr> {
|
||||
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::<MacAddr>().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<String> {
|
||||
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<String>, timeout: Duration) -> Option<MacAddr> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> Result<UdpSocket, Box<dyn std::error::Error>> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+31
-8
@@ -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<dyn Error>> {
|
||||
debug!("About to record notification in database");
|
||||
@@ -43,7 +53,10 @@ pub fn trigger_new_device(device: Device) -> Result<(), Box<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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,
|
||||
|
||||
+13
-4
@@ -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<dyn std::error::Error>> {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<String>,
|
||||
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();
|
||||
}
|
||||
@@ -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<DateTime<Utc>>,
|
||||
pub devices_discovered: u64,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MdnsScannerStatusSnapshot {
|
||||
pub is_listening: bool,
|
||||
pub listening_since: Option<DateTime<Utc>>,
|
||||
pub devices_discovered: u64,
|
||||
pub last_discovery_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
static STATUS: OnceCell<Mutex<MdnsScannerStatus>> = 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<MdnsScannerStatusSnapshot> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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("")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -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<dyn Error>> {
|
||||
.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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
#[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<Json<MdnsScannerStatusResponse>, 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,
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).";
|
||||
|
||||
Reference in New Issue
Block a user