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:
rzuasti
2026-05-29 09:37:33 -04:00
co-authored by Claude Opus 4.7
parent 2fe850c070
commit ca83009944
26 changed files with 778 additions and 157 deletions
+1
View File
@@ -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
+6
View File
@@ -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
+35 -98
View File
@@ -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",
+2
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
sudo CARGO_HOME=$HOME/.cargo cargo build
@@ -0,0 +1 @@
ALTER TABLE devices ADD COLUMN name TEXT;
+16 -4
View File
@@ -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
View File
@@ -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();
+13 -5
View File
@@ -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]
+2
View File
@@ -1,2 +1,4 @@
pub mod arp;
pub mod error;
pub mod mac_resolver;
pub mod mdns;
+13 -4
View File
@@ -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};
+148
View File
@@ -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);
}
}
+99
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+95
View File
@@ -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();
}
+105
View File
@@ -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());
}
}
+6 -2
View File
@@ -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("")
)
}
}
+7 -1
View File
@@ -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
)
}
}
+15
View File
@@ -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
// -----------------------------------------------------------
+13 -8
View File
@@ -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",
+4 -1
View File
@@ -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;
+59
View File
@@ -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,
}))
}
+4 -1
View File
@@ -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
+9 -4
View File
@@ -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).";