Add per-scanner enable/disable configuration

Each scanner (ARP, mDNS, SSDP/UPnP, DHCP) can now be turned off via an
`enabled` flag in its config section, defaulting to true so existing
deployments are unchanged. A disabled scanner's entry function returns
early and never starts.

Documented in the README options table (also fixing the stale `timings.*`
key names to the actual `arp_scanner.*` keys), and added to the TOML
samples and the NixOS module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-01 19:00:56 -04:00
co-authored by Claude Opus 4.8
parent 4e975598f9
commit 08ee4fc0ea
9 changed files with 160 additions and 30 deletions
+14 -8
View File
@@ -110,9 +110,9 @@ Finally, in your `configuration.nix` (or in an import file) enable and configure
database.path = "/var/lib/oott.db";
# networking.interface = "eth0"; # Optional: auto-detected if not set
log.level = "info";
timings.wait_between_scans = "15m";
timings.arp_sender_timeout = "20m";
timings.arp_scan_duration = "30m";
arp_scanner.wait_between_scans = "15m";
arp_scanner.sender_timeout = "20m";
arp_scanner.scan_duration = "30m";
notifications.method = "pushover";
notifications.notify_when_not_seen_for = "1w";
notifications.pushover.token = "YOUR API TOKEN GOES HERE";
@@ -135,9 +135,15 @@ If you are using Docker I recommend writing the config using TOML, [here](https:
|`database.path`|`/var/lib/oott.db`|Location of the system database|
|`networking.interface`|`eno1`|Network interface to use for scans. Optional — if not set, the first non-loopback connected interface is used automatically.|
|`log.level`|`info`|Log level to use (trace, debug, info, warn, error)|
|`timings.wait_between_scans`|`15m`|Time to wait between each network scan (you can express it in seconds, minutes, hours, etc. as a suffix - for example: 30s, 10m, 1h)|
|`timings.arp_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)|
|`timings.arp_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)|
|`arp_scanner.enabled`|`true`|Whether to run the ARP scanner. Defaults to enabled; set to `false` to turn it off.|
|`arp_scanner.wait_between_scans`|`15m`|Time to wait between each network scan (you can express it in seconds, minutes, hours, etc. as a suffix - for example: 30s, 10m, 1h)|
|`arp_scanner.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)|
|`arp_scanner.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.enabled`|`true`|Whether to run the mDNS/Bonjour scanner. Defaults to enabled; set to `false` to turn it off.|
|`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|
|`ssdp_scanner.enabled`|`true`|Whether to run the SSDP/UPnP scanner. Defaults to enabled; set to `false` to turn it off.|
|`ssdp_scanner.probe_timeout`|`2s`|When an SSDP/UPnP-discovered IP is not in the OS ARP cache, how long to wait for a targeted ARP probe reply to resolve its MAC address|
|`dhcp_scanner.enabled`|`true`|Whether to run the DHCP scanner. Defaults to enabled; set to `false` to turn it off.|
|`notifications.method`|`pushover`|For now just pushover, you can set this to "none" to avoid sending notifications (it will just log)|
|`notifications.notify_when_not_seen_for`|`1w`|Send a notification if a device comes back online after not being seen for this timeframe (you can use hours, weeks, etc.)|
|`notifications.pushover.token`||Your pushover token goes here, just copy&paste from their website after creating the app|
@@ -168,7 +174,7 @@ OOTT stores a timestamped event in the database for every device detected on eve
## Estimates by network size
The figures below assume a 1-minute scan duration (`arp_scan_duration = 1m`) with a 1-minute wait between scans (`wait_between_scans = 1m`), giving 720 scans per day, and a 365-day retention window. Assume all devices are continuously online (worst case).
The figures below assume a 1-minute scan duration (`arp_scanner.scan_duration = 1m`) with a 1-minute wait between scans (`arp_scanner.wait_between_scans = 1m`), giving 720 scans per day, and a 365-day retention window. Assume all devices are continuously online (worst case).
| Network size | Active devices | Storage / year |
|---|---|---|
@@ -189,7 +195,7 @@ where `145 bytes` covers the database row and its index entry, and `scans_per_da
Storage is directly proportional to scan frequency and retention window. The two main levers are:
**Scan interval**`timings.wait_between_scans` is the most effective knob. Increasing it reduces scans per day linearly:
**Scan interval**`arp_scanner.wait_between_scans` is the most effective knob. Increasing it reduces scans per day linearly:
| `wait_between_scans` | Scans/day (with 1m scan) | Storage vs. 1m+1m |
|---|---|---|
+1 -22
View File
@@ -4,40 +4,19 @@
## Backend
- [ ] Add configuration options to enable/disable each scanner
- [x] Call out in README.md that the system needs to be run as root (to bind the network card and listen on lower ports)
- [x] Add the scanner that triggered the event to the device_events table
- [x] Improve notifications layout/text
- [x] Add configuration options to enable/disable each scanner
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
- [x] Add SSDP/uPNP scanner
- [x] Make sure the new config options are in the TOML files and Nix module
- [x] Document in README.md the port needs/bindings
- [x] Add DHCP scanner
## Frontend
- [x] Add the SSDP/UPnP status to the status screen and home screen
- [x] Add the DHCP scanner status to the status screen and home screen
- [x] In the notifications list, modify the behavior so that you can fully see the text of the notification before navigating to the device details
- [ ] Can we add front-end tests?
- [x] In the devices list, add a color dot to reflect the last seen status (less than 10 minutes green, otherwise grey)
- [x] Change the URI for the homepage from /notifications to /home
- [x] In the devices list, the order by name is not consistent (iPad... before Lutron when ordering by name descending, maybe it should be case insensitive)
- [x] In the devices list, when the width is "medium", the list looks bad. Maybe make status pills not wrap to new lines
- [x] The ARP scanner status change the yellow to gray (yellow conveys problems, the scanner is just waiting)
## Improve engine
Several complementary approaches work well alongside ARP:
Passive (low noise, no probing):
- SSDP/UPnP — similar but for smart devices/IoT; multicast on 239.255.255.250:1900
- DHCP snooping — monitor DHCP DISCOVER/REQUEST packets; new devices must ask for an IP before doing anything else, so this catches them very early
- Passive packet capture — observe any broadcast/multicast traffic; a device that never responds to ARP still generates traffic
Active (you probe the network):
- ICMP ping sweep — ping every host in the subnet range; more universal than ARP but generates traffic
- TCP/UDP SYN scan — probe common ports (22, 80, 443, etc.); finds devices that silently drop ICMP
- NDP (Neighbor Discovery Protocol) — IPv6 equivalent of ARP; important if the network uses IPv6
Via infrastructure:
+5
View File
@@ -9,6 +9,11 @@ use log::{debug, info};
use tokio::time::{Duration, sleep};
pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
if !get_settings().arp_scanner.enabled {
info!("ARP scanner disabled in configuration; not starting");
return Ok(());
}
loop {
status::set_running();
+6
View File
@@ -9,12 +9,18 @@ use crate::db;
use crate::events;
use crate::model::device_events::DeviceEventScanner;
use crate::model::devices::Device;
use crate::settings::get_settings;
/// Passively snoop DHCP DISCOVER/REQUEST broadcasts and feed discovered devices into the
/// same pipeline used by the ARP, mDNS and SSDP scanners (devices table + events +
/// notifications). Because a device must request an address before doing almost anything
/// else, this catches new devices very early — often before they have an IP.
pub async fn listen() -> Result<(), Box<dyn std::error::Error>> {
if !get_settings().dhcp_scanner.enabled {
info!("DHCP scanner disabled in configuration; not starting");
return Ok(());
}
let socket = finder::open_socket()?;
status::set_listening();
info!("DHCP scanner listening for client requests");
+5
View File
@@ -17,6 +17,11 @@ use crate::settings::get_settings;
/// 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>> {
if !get_settings().mdns_scanner.enabled {
info!("mDNS scanner disabled in configuration; not starting");
return Ok(());
}
let interface = get_settings().networking.interface.clone();
let socket = finder::open_socket(interface.clone())?;
status::set_listening();
+5
View File
@@ -17,6 +17,11 @@ use crate::settings::get_settings;
/// Passively listen for SSDP/UPnP NOTIFY announcements and feed discovered devices into the same
/// pipeline used by the ARP and mDNS scanners (devices table + events + notifications).
pub async fn listen() -> Result<(), Box<dyn std::error::Error>> {
if !get_settings().ssdp_scanner.enabled {
info!("SSDP scanner disabled in configuration; not starting");
return Ok(());
}
let interface = get_settings().networking.interface.clone();
let socket = finder::open_socket(interface.clone())?;
status::set_listening();
+98
View File
@@ -6,6 +6,10 @@ use serde::Deserialize;
// -----------------------------------------------------------
// Configuration structure
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize, Clone)]
pub struct Database {
pub path: String,
@@ -23,6 +27,8 @@ pub struct Log {
#[derive(Debug, Deserialize, Clone)]
pub struct ArpScanner {
#[serde(default = "default_true")]
pub enabled: bool,
pub wait_between_scans: DurationString,
pub sender_timeout: DurationString,
pub scan_duration: DurationString,
@@ -30,12 +36,15 @@ pub struct ArpScanner {
#[derive(Debug, Deserialize, Clone)]
pub struct MdnsScanner {
#[serde(default = "default_true")]
pub enabled: bool,
pub probe_timeout: DurationString,
}
impl Default for MdnsScanner {
fn default() -> Self {
MdnsScanner {
enabled: true,
probe_timeout: DurationString::try_from("2s".to_string()).unwrap(),
}
}
@@ -43,17 +52,32 @@ impl Default for MdnsScanner {
#[derive(Debug, Deserialize, Clone)]
pub struct SsdpScanner {
#[serde(default = "default_true")]
pub enabled: bool,
pub probe_timeout: DurationString,
}
impl Default for SsdpScanner {
fn default() -> Self {
SsdpScanner {
enabled: true,
probe_timeout: DurationString::try_from("2s".to_string()).unwrap(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct DhcpScanner {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for DhcpScanner {
fn default() -> Self {
DhcpScanner { enabled: true }
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Pushover {
pub token: String,
@@ -101,6 +125,8 @@ pub struct Settings {
pub mdns_scanner: MdnsScanner,
#[serde(default)]
pub ssdp_scanner: SsdpScanner,
#[serde(default)]
pub dhcp_scanner: DhcpScanner,
}
// End configuration structure
// -----------------------------------------------------------
@@ -138,3 +164,75 @@ pub fn get_settings() -> &'static Settings {
pub fn init(config_path: String) {
let _ = SETTINGS.set(Settings::new(config_path).unwrap());
}
#[cfg(test)]
mod tests {
use super::*;
use config::{Config, FileFormat};
fn parse(toml: &str) -> Settings {
Config::builder()
.add_source(config::File::from_str(toml, FileFormat::Toml))
.build()
.unwrap()
.try_deserialize()
.unwrap()
}
const BASE_CONFIG: &str = r#"
[database]
path = "./oott.db"
[networking]
[log]
level = "info"
[arp_scanner]
wait_between_scans = "1m"
sender_timeout = "1m"
scan_duration = "2m"
[notifications]
method = "none"
notify_when_not_seen_for = "1w"
[notifications.pushover]
token = ""
user_key = ""
[web_server]
ip_address = "0.0.0.0"
port = 3000
api_key = "test"
"#;
#[test]
fn scanners_enabled_by_default_when_flag_omitted() {
let settings = parse(BASE_CONFIG);
assert!(settings.arp_scanner.enabled);
assert!(settings.mdns_scanner.enabled);
assert!(settings.ssdp_scanner.enabled);
assert!(settings.dhcp_scanner.enabled);
}
#[test]
fn scanners_can_be_disabled() {
let toml = format!(
"{BASE_CONFIG}
[mdns_scanner]
enabled = false
probe_timeout = \"2s\"
[ssdp_scanner]
enabled = false
probe_timeout = \"2s\"
[dhcp_scanner]
enabled = false
"
);
// Disable the ARP scanner via its existing section too.
let toml = toml.replace(
"wait_between_scans = \"1m\"",
"enabled = false\n wait_between_scans = \"1m\"",
);
let settings = parse(&toml);
assert!(!settings.arp_scanner.enabled);
assert!(!settings.mdns_scanner.enabled);
assert!(!settings.ssdp_scanner.enabled);
assert!(!settings.dhcp_scanner.enabled);
}
}
+6
View File
@@ -8,16 +8,22 @@ path = "./oott.db" # Database path. Make sure it's writeable for the user runnin
level = "info" # off, error, warn, info, debug, trace
[arp_scanner]
enabled=true # Set to false to disable the ARP scanner
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]
enabled=true # Set to false to disable the mDNS/Bonjour 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
[ssdp_scanner]
enabled=true # Set to false to disable the SSDP/UPnP scanner
probe_timeout="2s" # When an SSDP/UPnP-discovered IP is not in the OS ARP cache, how long to wait for a targeted ARP probe reply to resolve its MAC address
[dhcp_scanner]
enabled=true # Set to false to disable the DHCP scanner
[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
+20
View File
@@ -31,6 +31,11 @@ in {
description = "Log level for the oott service";
default = "info";
};
arp_scanner.enabled = mkOption {
type = types.bool;
description = "Whether to run the ARP scanner.";
default = true;
};
arp_scanner.wait_between_scans = mkOption {
type = types.str;
description = "Wait time between scans. This does not include the scan time.";
@@ -46,16 +51,31 @@ in {
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.enabled = mkOption {
type = types.bool;
description = "Whether to run the mDNS/Bonjour scanner.";
default = true;
};
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";
};
ssdp_scanner.enabled = mkOption {
type = types.bool;
description = "Whether to run the SSDP/UPnP scanner.";
default = true;
};
ssdp_scanner.probe_timeout = mkOption {
type = types.str;
description = "When an SSDP/UPnP-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";
};
dhcp_scanner.enabled = mkOption {
type = types.bool;
description = "Whether to run the DHCP scanner.";
default = true;
};
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).";